diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..db3f682 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "buzzr-sports-engine", + "version": "0.1.0", + "description": "Auditable sports math, DFS settlement, and game-entertainment workflows for Codex.", + "author": { + "name": "Buzzr", + "url": "https://github.com/Buzzr-app" + }, + "homepage": "https://buzzr-app.github.io/dfs-engine/", + "repository": "https://github.com/Buzzr-app/dfs-engine", + "license": "MIT", + "keywords": [ + "sports", + "dfs", + "odds", + "mcp", + "settlement" + ], + "skills": "./skills/", + "interface": { + "displayName": "Buzzr Sports Engine", + "shortDescription": "Auditable sports math and DFS settlement", + "longDescription": "Use deterministic Buzzr engines for sportsbook odds math, DFS pick-em settlement, bet-history analytics, and game-entertainment scoring.", + "developerName": "Buzzr", + "category": "Developer Tools", + "capabilities": [ + "Interactive" + ], + "websiteURL": "https://buzzr-app.github.io/dfs-engine/", + "supportURL": "https://github.com/Buzzr-app/dfs-engine/issues", + "defaultPrompt": [ + "Audit this DFS entry with its displayed terms and explain every settlement decision.", + "Calculate the no-vig fair line and closing-line value for these prices." + ], + "brandColor": "#FF6B35", + "screenshots": [] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d5aefd..6b18fcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,15 +6,21 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 30 strategy: matrix: node: [22, 24] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node }} cache: npm @@ -25,9 +31,39 @@ jobs: - run: npm test - run: npm run test:coverage - run: npm run build + - run: npm run test:mcp:packed + - if: matrix.node == 22 + run: npm run test:packages:packed + - run: npm run check:mcp:client-skill + - run: npm run check:mcp:examples + - run: npm run check:skill - run: npm run docs + - run: npm run check:docs + - run: npm run check:links + # External URLs are checked only after reviewed code reaches main. PR + # Markdown must not turn the shared CI runner into an arbitrary URL probe. + - if: matrix.node == 22 && github.event_name == 'push' + run: npm run check:links:external - run: npm run smoke:exports - run: npm run size:check - run: npm run pack:smoke - run: npm run bench - run: npm run audit:high + + mcp-packed-platforms: + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:mcp:packed diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 23855cd..f574eda 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,14 +2,11 @@ name: docs on: push: - tags: - - 'v*' + branches: + - main workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +permissions: {} concurrency: group: pages @@ -17,25 +14,35 @@ concurrency: jobs: build: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm - run: npm ci - run: npm run docs - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: - path: packages/dfs-engine/docs + path: docs/api deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/prove-mcp-published.yml b/.github/workflows/prove-mcp-published.yml new file mode 100644 index 0000000..58ce442 --- /dev/null +++ b/.github/workflows/prove-mcp-published.yml @@ -0,0 +1,43 @@ +name: prove published MCP + +on: + workflow_dispatch: + inputs: + expected_version: + description: Published @buzzr/mcp version expected on npm latest + required: true + type: string + expected_integrity: + description: Reviewed npm dist.integrity for the exact release + required: true + type: string + expected_git_head: + description: Reviewed 40-character git commit for the exact release + required: true + type: string + +permissions: + contents: read + +jobs: + prove: + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run proof:mcp:published + env: + EXPECTED_MCP_VERSION: ${{ inputs.expected_version }} + EXPECTED_MCP_INTEGRITY: ${{ inputs.expected_integrity }} + EXPECTED_GIT_HEAD: ${{ inputs.expected_git_head }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a371b84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,563 @@ +name: release + +on: + workflow_dispatch: + inputs: + expected_version: + description: Exact synchronized release version, without the v prefix + required: true + type: string + expected_commit: + description: Reviewed 40-character commit on main to publish + required: true + type: string + confirm_publish: + description: Type PUBLISH to authorize the irreversible npm release + required: true + type: string + +permissions: + contents: read + +concurrency: + group: buzzr-release + cancel-in-progress: false + +jobs: + authorize-release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + reviewed-commit: ${{ steps.authorize.outputs.commit }} + manifest-sha512: ${{ steps.authorize.outputs.manifest-sha512 }} + steps: + - name: Authorize reviewed main-history commit + id: authorize + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_COMMIT: ${{ inputs.expected_commit }} + EXPECTED_RELEASE_VERSION: ${{ inputs.expected_version }} + CONFIRM_PUBLISH: ${{ inputs.confirm_publish }} + GITHUB_SHA_AT_DISPATCH: ${{ github.sha }} + run: | + set -euo pipefail + [[ "$CONFIRM_PUBLISH" == "PUBLISH" ]] + [[ "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ "$EXPECTED_RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]] + REMOTE_MAIN="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + EXPECTED_MERGE_BASE="$(gh api \ + "repos/$GITHUB_REPOSITORY/compare/$EXPECTED_COMMIT...$GITHUB_SHA_AT_DISPATCH" \ + --jq '.merge_base_commit.sha')" + test "$EXPECTED_MERGE_BASE" = "$EXPECTED_COMMIT" + DISPATCH_MERGE_BASE="$(gh api \ + "repos/$GITHUB_REPOSITORY/compare/$GITHUB_SHA_AT_DISPATCH...$REMOTE_MAIN" \ + --jq '.merge_base_commit.sha')" + test "$DISPATCH_MERGE_BASE" = "$GITHUB_SHA_AT_DISPATCH" + gh api \ + --header 'Accept: application/vnd.github.raw+json' \ + "repos/$GITHUB_REPOSITORY/contents/release-manifest.json?ref=$EXPECTED_COMMIT" \ + > "$RUNNER_TEMP/release-manifest.json" + manifest_sha512="$(sha512sum "$RUNNER_TEMP/release-manifest.json" | cut -d' ' -f1)" + echo "commit=$EXPECTED_COMMIT" >> "$GITHUB_OUTPUT" + echo "manifest-sha512=$manifest_sha512" >> "$GITHUB_OUTPUT" + + build-release-artifacts: + needs: authorize-release + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + outputs: + artifacts: ${{ steps.pack.outputs.artifacts }} + integrities: ${{ steps.pack.outputs.integrities }} + mcp-integrity: ${{ steps.pack.outputs.mcp-integrity }} + artifact-manifest-sha512: ${{ steps.pack.outputs.artifact-manifest-sha512 }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.authorize-release.outputs.reviewed-commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + package-manager-cache: false + - name: Validate checked-out release + shell: bash + env: + EXPECTED_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + EXPECTED_RELEASE_VERSION: ${{ inputs.expected_version }} + EXPECTED_RELEASE_MANIFEST_SHA512: ${{ needs.authorize-release.outputs.manifest-sha512 }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" + test "$(sha512sum release-manifest.json | cut -d' ' -f1)" = "$EXPECTED_RELEASE_MANIFEST_SHA512" + node scripts/check-release-version.mjs + - name: Install reviewed npm CLI and dependencies + run: | + npm install --global npm@12.0.1 + npm --version + npm ci --ignore-scripts + - name: Run complete release verification + run: npm run verify + - name: Confirm generated artifacts did not change the release commit + run: | + git diff --exit-code + git diff --cached --exit-code + test -z "$(git ls-files --others --exclude-standard)" + - name: Pack and hash all five authorized artifacts + id: pack + shell: bash + env: + EXPECTED_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + EXPECTED_RELEASE_MANIFEST_SHA512: ${{ needs.authorize-release.outputs.manifest-sha512 }} + run: | + set -euo pipefail + bundle="$RUNNER_TEMP/release-packs" + release_source="$RUNNER_TEMP/release-source" + artifacts_file="$bundle/release-artifacts.json" + mkdir -p "$release_source" + git archive HEAD | tar --extract --directory "$release_source" + RELEASE_SOURCE="$release_source" EXPECTED_COMMIT="$EXPECTED_COMMIT" node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { cp, readFile, writeFile } from 'node:fs/promises'; + import { join, resolve } from 'node:path'; + + assert.match(process.env.EXPECTED_COMMIT, /^[0-9a-f]{40}$/); + const source = resolve(process.env.RELEASE_SOURCE); + const release = JSON.parse(await readFile('release-manifest.json', 'utf8')); + const packages = release.packages.filter(({ publish }) => publish); + assert.equal(packages.length, 5); + for (const entry of packages) { + const packageDirectory = join(source, entry.path); + const stagedPackagePath = join(packageDirectory, 'package.json'); + const packageJson = JSON.parse(await readFile(stagedPackagePath, 'utf8')); + assert.equal(packageJson.name, entry.name); + assert.equal(packageJson.version, entry.version); + await writeFile( + stagedPackagePath, + `${JSON.stringify({ ...packageJson, gitHead: process.env.EXPECTED_COMMIT }, null, 2)}\n`, + ); + await cp(join(entry.path, 'dist'), join(packageDirectory, 'dist'), { + recursive: true, + force: true, + }); + } + NODE + artifacts="$(cd "$release_source" && node scripts/pack-release-artifacts.mjs \ + --destination "$bundle" \ + --output "$artifacts_file")" + cp release-manifest.json "$bundle/release-manifest.json" + test "$(sha512sum "$bundle/release-manifest.json" | cut -d' ' -f1)" = "$EXPECTED_RELEASE_MANIFEST_SHA512" + artifact_manifest_sha512="$(sha512sum "$artifacts_file" | cut -d' ' -f1)" + integrities="$(node -e 'const a=require(process.argv[1]);process.stdout.write(JSON.stringify(Object.fromEntries(a.map(({name,integrity})=>[name,integrity]))))' "$artifacts_file")" + mcp_integrity="$(node -e 'const a=require(process.argv[1]);process.stdout.write(a.find(({name})=>name==="@buzzr/mcp").integrity)' "$artifacts_file")" + echo "artifacts=$artifacts" >> "$GITHUB_OUTPUT" + echo "integrities=$integrities" >> "$GITHUB_OUTPUT" + echo "mcp-integrity=$mcp_integrity" >> "$GITHUB_OUTPUT" + echo "artifact-manifest-sha512=$artifact_manifest_sha512" >> "$GITHUB_OUTPUT" + node -e 'const a=require(process.argv[1]);for(const x of a)console.log(`- ${x.name}@${x.version}: ${x.integrity}`)' "$artifacts_file" >> "$GITHUB_STEP_SUMMARY" + - name: Transfer the exact prepacked release bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-release-${{ needs.authorize-release.outputs.reviewed-commit }} + path: ${{ runner.temp }}/release-packs + if-no-files-found: error + retention-days: 1 + compression-level: 0 + include-hidden-files: false + + publish-npm: + needs: [authorize-release, build-release-artifacts] + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.authorize-release.outputs.reviewed-commit }} + fetch-depth: 1 + persist-credentials: false + - name: Bind npm publication to the reviewed checkout + shell: bash + env: + EXPECTED_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + package-manager-cache: false + - name: Download the reviewed release bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: npm-release-${{ needs.authorize-release.outputs.reviewed-commit }} + path: ${{ runner.temp }}/release-packs + - name: Revalidate and publish only the five literal tarballs + shell: bash + env: + EXPECTED_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + EXPECTED_RELEASE_VERSION: ${{ inputs.expected_version }} + EXPECTED_RELEASE_MANIFEST_SHA512: ${{ needs.authorize-release.outputs.manifest-sha512 }} + EXPECTED_ARTIFACT_MANIFEST_SHA512: ${{ needs.build-release-artifacts.outputs.artifact-manifest-sha512 }} + RELEASE_BUNDLE: ${{ runner.temp }}/release-packs + PUBLISH_ORDER_FILE: ${{ runner.temp }}/publish-order.tsv + GH_TOKEN: ${{ github.token }} + NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/empty-user.npmrc + NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/empty-global.npmrc + run: | + set -euo pipefail + : > "$NPM_CONFIG_USERCONFIG" + : > "$NPM_CONFIG_GLOBALCONFIG" + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { execFileSync } from 'node:child_process'; + import { createHash } from 'node:crypto'; + import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; + import { basename, join, resolve } from 'node:path'; + + const bundle = resolve(process.env.RELEASE_BUNDLE); + const releaseManifestPath = join(bundle, 'release-manifest.json'); + const artifactManifestPath = join(bundle, 'release-artifacts.json'); + const sha512 = async (path, encoding) => + createHash('sha512').update(await readFile(path)).digest(encoding); + + assert.equal( + await sha512(releaseManifestPath, 'hex'), + process.env.EXPECTED_RELEASE_MANIFEST_SHA512, + 'release manifest changed after authorization', + ); + assert.equal( + await sha512(artifactManifestPath, 'hex'), + process.env.EXPECTED_ARTIFACT_MANIFEST_SHA512, + 'artifact manifest changed in transfer', + ); + + const release = JSON.parse(await readFile(releaseManifestPath, 'utf8')); + const artifacts = JSON.parse(await readFile(artifactManifestPath, 'utf8')); + const packages = release.packages.filter(({ publish }) => publish); + assert.equal(release.releaseVersion, process.env.EXPECTED_RELEASE_VERSION); + assert.equal(artifacts.length, 5, 'exactly five npm artifacts are authorized'); + assert.equal(packages.length, 5, 'exactly five manifest packages are authorized'); + + const publishOrder = [ + '@buzzr/dfs-engine', + '@buzzr/dfs-engine-test-vectors', + '@buzzr/dfs-cli', + '@buzzr/dfs-testkit', + '@buzzr/mcp', + ]; + assert.deepEqual( + packages.map(({ name }) => name).sort(), + [...publishOrder].sort(), + 'release manifest package allowlist changed', + ); + assert.deepEqual( + artifacts.map(({ name }) => name).sort(), + [...publishOrder].sort(), + 'artifact package allowlist changed', + ); + + const artifactsByName = new Map(artifacts.map((artifact) => [artifact.name, artifact])); + assert.equal(artifactsByName.size, 5, 'artifact package names must be unique'); + const expectedFiles = ['release-artifacts.json', 'release-manifest.json']; + const lines = []; + for (const name of publishOrder) { + const artifact = artifactsByName.get(name); + const entry = packages.find((candidate) => candidate.name === name); + assert(artifact && entry, `${name} must exist in both manifests`); + assert.equal(artifact.version, entry.version, `${name} version changed while packing`); + assert.equal(artifact.filename, basename(artifact.filename), `${name} filename is unsafe`); + assert.match(artifact.filename, /^buzzr-[a-z0-9-]+-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\.tgz$/); + assert.match(artifact.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); + const tarball = join(bundle, artifact.filename); + const tarballStat = await stat(tarball); + assert.equal(tarballStat.isFile(), true, `${name} tarball must be a regular file`); + assert.equal(tarballStat.size, artifact.size, `${name} tarball size changed in transfer`); + const tarballPackage = JSON.parse( + execFileSync( + 'tar', + ['--extract', '--gzip', '--to-stdout', '--file', tarball, 'package/package.json'], + { encoding: 'utf8', maxBuffer: 1024 * 1024 }, + ), + ); + assert.equal(tarballPackage.name, artifact.name, `${name} tarball name changed`); + assert.equal(tarballPackage.version, artifact.version, `${name} tarball version changed`); + assert.equal( + tarballPackage.gitHead, + process.env.EXPECTED_COMMIT, + `${name} tarball is not bound to the reviewed commit`, + ); + assert.equal( + `sha512-${await sha512(tarball, 'base64')}`, + artifact.integrity, + `${name} tarball integrity changed in transfer`, + ); + expectedFiles.push(artifact.filename); + lines.push([artifact.name, artifact.version, artifact.integrity, artifact.filename].join('\t')); + } + const entries = await readdir(bundle, { withFileTypes: true }); + assert(entries.every((entry) => entry.isFile()), 'release bundle may contain only files'); + const actualFiles = entries.map(({ name }) => name).sort(); + expectedFiles.sort(); + assert.deepEqual(actualFiles, expectedFiles, 'release bundle has extra or missing files'); + await writeFile(process.env.PUBLISH_ORDER_FILE, `${lines.join('\n')}\n`, { mode: 0o600 }); + NODE + + npm_version="$(npm --version)" + node -e 'const [major,minor,patch]=process.argv[1].split(".").map(Number);if(major<11||(major===11&&(minor<5||(minor===5&&patch<1))))process.exit(1)' "$npm_version" + declare -A preexisting_exact=() + existing_exact_count=0 + while IFS=$'\t' read -r package_name version integrity filename; do + registry_response='' + if registry_response="$(npm view "$package_name@$version" dist.integrity --json 2>&1)"; then + live_integrity="$(node -e 'const v=JSON.parse(process.argv[1]);if(typeof v!=="string")process.exit(1);process.stdout.write(v)' "$registry_response")" + test "$live_integrity" = "$integrity" + preexisting_exact["$package_name"]=1 + existing_exact_count=$((existing_exact_count + 1)) + elif [[ "$registry_response" != *"E404"* ]]; then + printf '%s\n' "$registry_response" >&2 + exit 1 + fi + done < "$PUBLISH_ORDER_FILE" + + if [[ "$existing_exact_count" -eq 0 ]]; then + REMOTE_MAIN="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + test "$REMOTE_MAIN" = "$EXPECTED_COMMIT" + fi + + while IFS=$'\t' read -r package_name version integrity filename; do + if [[ "${preexisting_exact["$package_name"]:-0}" -eq 1 ]]; then + continue + fi + tarball="$RELEASE_BUNDLE/$filename" + if npm publish "$tarball" --ignore-scripts --provenance; then + continue + fi + publish_converged=false + for attempt in {1..3}; do + if registry_response="$(npm view "$package_name@$version" dist.integrity --json 2>&1)"; then + live_integrity="$(node -e 'const v=JSON.parse(process.argv[1]);if(typeof v!=="string")process.exit(1);process.stdout.write(v)' "$registry_response")" + if [[ "$live_integrity" != "$integrity" ]]; then + printf 'npm integrity conflict for %s@%s\n' "$package_name" "$version" >&2 + exit 1 + fi + publish_converged=true + break + fi + if [[ "$attempt" -lt 3 ]]; then + sleep 5 + fi + done + test "$publish_converged" = true + done < "$PUBLISH_ORDER_FILE" + + prove-published-npm: + needs: [authorize-release, build-release-artifacts, publish-npm] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.authorize-release.outputs.reviewed-commit }} + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + package-manager-cache: false + - name: Install the reviewed proof dependencies + run: | + npm install --global npm@12.0.1 + npm --version + npm ci --ignore-scripts + - name: Prove all exact live npm artifacts + shell: bash + env: + EXPECTED_RELEASE_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + EXPECTED_RELEASE_INTEGRITIES: ${{ needs.build-release-artifacts.outputs.integrities }} + EXPECTED_MCP_VERSION: ${{ inputs.expected_version }} + EXPECTED_MCP_INTEGRITY: ${{ needs.build-release-artifacts.outputs.mcp-integrity }} + EXPECTED_GIT_HEAD: ${{ needs.authorize-release.outputs.reviewed-commit }} + run: | + set -euo pipefail + proof_succeeded=false + for attempt in {1..12}; do + if node scripts/prove-published-release.mjs; then + proof_succeeded=true + break + fi + if [[ "$attempt" -lt 12 ]]; then + sleep 10 + fi + done + test "$proof_succeeded" = true + npm run proof:mcp:published + + publish-mcp-registry: + needs: [authorize-release, prove-published-npm] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ needs.authorize-release.outputs.reviewed-commit }} + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + package-manager-cache: false + - name: Install the reviewed MCP Registry publisher + shell: bash + run: | + set -euo pipefail + publisher_dir="$RUNNER_TEMP/mcp-publisher-v1.7.9" + archive="$RUNNER_TEMP/mcp-publisher_linux_amd64-v1.7.9.tar.gz" + mkdir -p "$publisher_dir" + curl --proto '=https' --proto-redir '=https' --tlsv1.2 --fail --silent --show-error --location \ + --output "$archive" \ + 'https://github.com/modelcontextprotocol/registry/releases/download/v1.7.9/mcp-publisher_linux_amd64.tar.gz' + printf '%s %s\n' 'ab128162b0616090b47cf245afe0a23f3ef08936fdce19074f5ba0a4469281ac' "$archive" | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory "$publisher_dir" \ + --no-same-owner mcp-publisher + chmod 0755 "$publisher_dir/mcp-publisher" + "$publisher_dir/mcp-publisher" --help >/dev/null + echo "$publisher_dir" >> "$GITHUB_PATH" + - name: Publish the exact server metadata when absent + shell: bash + run: | + set -euo pipefail + probe_status=1 + for attempt in {1..3}; do + set +e + MCP_REGISTRY_ALLOW_MISSING=1 node scripts/prove-mcp-registry-record.mjs + probe_status=$? + set -e + if [[ "$probe_status" -eq 0 || "$probe_status" -eq 3 ]]; then + break + fi + sleep 5 + done + if [[ "$probe_status" -eq 3 ]]; then + mcp-publisher login github-oidc + if ! mcp-publisher publish; then + node scripts/prove-mcp-registry-record.mjs + fi + elif [[ "$probe_status" -ne 0 ]]; then + exit "$probe_status" + fi + - name: Prove the exact official MCP Registry record + shell: bash + run: | + set -euo pipefail + proof_succeeded=false + for attempt in {1..12}; do + if node scripts/prove-mcp-registry-record.mjs; then + proof_succeeded=true + break + fi + if [[ "$attempt" -lt 12 ]]; then + sleep 10 + fi + done + test "$proof_succeeded" = true + + github-release: + needs: [authorize-release, build-release-artifacts, prove-published-npm, publish-mcp-registry] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Create the immutable GitHub release and tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.expected_version }} + RELEASE_COMMIT: ${{ needs.authorize-release.outputs.reviewed-commit }} + RELEASE_ARTIFACTS: ${{ needs.build-release-artifacts.outputs.artifacts }} + run: | + set -euo pipefail + tag="v$RELEASE_VERSION" + notes_file="$RUNNER_TEMP/release-notes.md" + node --input-type=module -e ' + import assert from "node:assert/strict"; + const artifacts = JSON.parse(process.env.RELEASE_ARTIFACTS); + assert.equal(artifacts.length, 5); + const lines = [ + `Buzzr public toolkit release \`v${process.env.RELEASE_VERSION}\`.`, + "", + `Published from reviewed commit \`${process.env.RELEASE_COMMIT}\` through npm trusted publishing with provenance.`, + "", + "| Package | Version | npm integrity |", + "| --- | --- | --- |", + ]; + for (const artifact of artifacts) { + assert.match(artifact.name, /^@buzzr\/[a-z0-9-]+$/); + assert.match(artifact.version, /^\d+\.\d+\.\d+$/); + assert.match(artifact.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); + lines.push(`| \`${artifact.name}\` | \`${artifact.version}\` | \`${artifact.integrity}\` |`); + } + process.stdout.write(`${lines.join("\n")}\n`); + ' > "$notes_file" + + tag_response='' + if tag_response="$(gh api \ + --method POST \ + "repos/$GITHUB_REPOSITORY/git/refs" \ + --field "ref=refs/tags/$tag" \ + --field "sha=$RELEASE_COMMIT" 2>&1)"; then + CREATED_TAG_RESPONSE="$tag_response" EXPECTED_TAG="refs/tags/$tag" EXPECTED_SHA="$RELEASE_COMMIT" \ + node --input-type=module -e ' + import assert from "node:assert/strict"; + const created = JSON.parse(process.env.CREATED_TAG_RESPONSE); + assert.equal(created.ref, process.env.EXPECTED_TAG); + assert.equal(created.object?.type, "commit"); + assert.equal(created.object?.sha, process.env.EXPECTED_SHA); + ' + else + [[ "$tag_response" == *"HTTP 422"* ]] + fi + tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq '.sha')" + test "$tag_commit" = "$RELEASE_COMMIT" + + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + existing_body="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq '.body')" + EXPECTED_NOTES="$(cat "$notes_file")" EXISTING_BODY="$existing_body" node --input-type=module -e ' + import assert from "node:assert/strict"; + assert.equal(process.env.EXISTING_BODY, process.env.EXPECTED_NOTES); + ' + else + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "$tag" \ + --notes-file "$notes_file" + fi + + release_tag="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json tagName --jq '.tagName')" + release_title="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json name --jq '.name')" + released_body="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq '.body')" + test "$release_tag" = "$tag" + test "$release_title" = "$tag" + EXPECTED_NOTES="$(cat "$notes_file")" RELEASED_BODY="$released_body" node --input-type=module -e ' + import assert from "node:assert/strict"; + assert.equal(process.env.RELEASED_BODY, process.env.EXPECTED_NOTES); + ' + tag_commit_after_release="$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq '.sha')" + test "$tag_commit_after_release" = "$RELEASE_COMMIT" diff --git a/AGENTS.md b/AGENTS.md index 0247dce..fbb6cbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidance for AI coding agents working in this monorepo or consuming the publishe ## What this repo is -An npm workspaces monorepo (`packages/*`) publishing ten pure-TypeScript packages for sports betting / DFS apps. Three core engines, one MCP server, six satellites. Everything is pure functions: no I/O, no network, no framework runtime, zero runtime dependencies outside the `@buzzr/*` family. +An npm workspaces monorepo (`packages/*`) publishing ten TypeScript packages for sports betting / DFS apps: three pure core engines, one stdio MCP server, one filesystem CLI, and five provider/UI/testing satellites. The core engines perform no I/O and have zero external runtime dependencies. Boundary packages have explicit jobs: the CLI reads files, provider packages call consumer-supplied loaders, and the MCP server uses the official MCP SDK plus Zod. ## Package map and API entry points @@ -15,24 +15,27 @@ Each package's public API is exactly what `packages//src/index.ts` exports | `@buzzr/dfs-engine` | `packages/dfs-engine` | `createDfsEngine`, `defineStatProvider`, `defineBookPolicy`, `engine.settleEntry`, `engine.settleEntries` (v5 batch) | | `@buzzr/bets-core` | `packages/bets-core` | `calculateNoVigFairLine`, `combineAmericanOdds`, `calculateParlayFairValue`, `calculateExpectedValue`, `calculateKellyStake`, `calculateClosingLineValue`, `calculateRollupByPeriod` | | `@buzzr/entertainment-engine` | `packages/entertainment-engine` | `resolveBuzzScores`, `enrichGameRowWithBuzzScores`, `isMustWatch`, `rankGamesForUser` | -| `@buzzr/mcp` | `packages/mcp` | MCP server, bin `buzzr-mcp` (run via `npx -y @buzzr/mcp`), 8 tools over the three engines | +| `@buzzr/mcp` | `packages/mcp` | MCP server, bins `mcp` / `buzzr-mcp`, 11 tools over the three engines | | `@buzzr/dfs-cli` | `packages/dfs-cli` | bin `dfs-grade`; `runGrade`, `runGradeFromFiles` | | `@buzzr/dfs-react` | `packages/dfs-react` | `getSlipDisplayModel`, `getStatusTone`, `formatLegLabel`, `formatLegLine` | | `@buzzr/dfs-testkit` | `packages/dfs-testkit` | `makeDfsEntry`, `makeDfsLeg`, `makeGameLogEntry`, `createMockStatProvider`, `makeInvalidDfsEntry` | | `@buzzr/dfs-provider-espn` | `packages/dfs-provider-espn` | `createEspnStatProvider` | | `@buzzr/dfs-provider-sportradar` | `packages/dfs-provider-sportradar` | `createSportradarStatProvider`, `sportradarRowToGameLog` | -| `@buzzr/dfs-engine-test-vectors` | `packages/dfs-engine-test-vectors` | `TEST_VECTORS` | +| `@buzzr/dfs-engine-test-vectors` | `packages/dfs-engine-test-vectors` | `TEST_VECTORS` engine regression fixtures | -API reference: https://buzzr-app.github.io/dfs-engine/ (typedoc, generated from `@buzzr/dfs-engine`). +See the [all-package API index](docs/api-reference.md). The generated +[TypeDoc site](https://buzzr-app.github.io/dfs-engine/) covers the supported +root exports of all ten public packages. ## Invariants — do not break these -1. **Zero runtime dependencies.** No package may add a runtime dependency outside the `@buzzr/*` family. If a change needs a library, it does not belong in these packages. -2. **Pure functions only.** No I/O, no network calls, no timers, no global mutable state in engine code. Data providers are injected by the consumer (`StatProvider`, etc.); the engines never fetch. +1. **Keep the cores dependency-free.** The three core engines have zero external runtime dependencies. Boundary packages may use reviewed dependencies required by their contract; `@buzzr/mcp` already uses the official MCP SDK and Zod. Never add a dependency casually, and keep settlement math out of wrappers. +2. **Keep engine code pure.** No I/O, network calls, timers, or global mutable state in core engine execution. Data providers are injected by the consumer (`StatProvider`, etc.). The CLI and MCP server are intentionally I/O boundaries, not pure-function packages. 3. **v4 canonical shapes are frozen contracts.** `DfsEntryInput`, `DfsLegInput` (`legId`, `actual`, `status`, `direction`, `line`, …), and `PlayerGameLogEntryShape` (string-valued stat fields: `points: '31'`) are the interchange formats. Changing a field name or type is a breaking change requiring a major version — never do it casually. 4. **Settlement must stay explainable.** Every settlement result carries `validation`, `provenance`, `auditTrail`, and `explanationCodes`. New settlement paths must populate them; never return a bare status. -5. **The verify gate is the definition of done.** `npm run verify` (typecheck, lint, format check, tests, coverage, build, docs, export smoke tests, package size checks, dry-run pack) must pass before any release-bound change is complete. -6. **Golden vectors are conformance law.** If a change alters the outcome of any fixture in `@buzzr/dfs-engine-test-vectors`, that is a breaking behavioral change — flag it, don't silently update the vectors. +5. **Run the complete release proof.** `npm run verify` covers typecheck, lint, formatting, tests and coverage, build, packed-MCP real-client proof, TypeDoc, exports, package size/pack smoke, release workflows, MCP Registry metadata, and high-severity dependency audit. Public documentation additionally requires `node scripts/check-public-docs.mjs`. +6. **Treat vectors as versioned review gates.** `@buzzr/dfs-engine-test-vectors` publishes engine regression fixtures. A changed outcome requires explanation, compatibility analysis, and the appropriate version; never silently rewrite expected results. The fixtures are not official operator conformance. +7. **Keep operator claims bounded.** PrizePicks is an experimental/partial compatibility profile and Underdog is experimental/unverified. Displayed entry terms and explicit operator rulings are authoritative; drafts remain non-executable. ## Working in the repo @@ -41,19 +44,29 @@ npm ci # install (Node >= 22 required) npm run typecheck # tsc across all workspaces npm test # vitest across all workspaces npm run lint # eslint on packages/*/src and packages/*/tests -npm run build # tsup builds (ESM + CJS + d.ts) +npm run build # package-defined build outputs npm run verify # full release gate +node scripts/check-public-docs.mjs ``` - Tests live in `packages//tests` and run with vitest. -- Prettier formats `packages/*/{src,tests,examples}` and root-level `*.{json,md}` — run `npm run format` before committing doc or config changes. -- Changesets (`@changesets/cli`) drive versioning; all packages currently release in lockstep (5.x). +- The repository's Prettier ignore file excludes Markdown. Run the public-docs contract and `git diff --check` for Markdown; run `npm run format:check` for the configured code/JSON surface. +- Changesets (`@changesets/cli`) drive independent package releases. Select the smallest semver changes justified by public API/behavior and update dependents only when their pins or contracts require it. ## Consuming the packages (for agents writing integration code) - To grade a DFS entry: build a `DfsEntryInput`, register a `StatProvider` that returns `PlayerGameLogEntryShape[]` rows, call `engine.settleEntry(entry, { statProviderId })`. See `packages/dfs-cli/src/index.ts` for a minimal end-to-end example. -- To verify an integration, replay `TEST_VECTORS` from `@buzzr/dfs-engine-test-vectors` and assert identical statuses and per-leg actuals. -- For AI-agent runtimes, prefer the MCP server (`npx -y @buzzr/mcp`) over reimplementing odds/settlement math in prompts. +- To verify an integration, replay `TEST_VECTORS` from `@buzzr/dfs-engine-test-vectors` against the matching engine version and inspect the full expected policy, payout, validation, provenance, explanation, audit, pending, and leg contract. +- For AI-agent runtimes, prefer the MCP server (`npx -y @buzzr/mcp@5.1.0`) over reimplementing odds/settlement math in prompts. Call `list_book_policies` before grading and use only `executable: true` policies. + +## Public documentation + +- [Architecture and data flow](docs/architecture.md) +- [Security, privacy, and threat model](docs/security-and-privacy.md) +- [Versioning, compatibility, and support](docs/versioning-and-support.md) +- [All-package API index](docs/api-reference.md) +- [MCP installation and contracts](packages/mcp/README.md) +- [Repository-owned Codex skill](skills/buzzr-sports-engine/SKILL.md) ## Machine-readable index diff --git a/README.md b/README.md index 39a9934..2e2c1d2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![node](https://img.shields.io/node/v/@buzzr/dfs-engine)](https://github.com/Buzzr-app/dfs-engine) [![docs](https://img.shields.io/badge/docs-typedoc-blue)](https://buzzr-app.github.io/dfs-engine/) -**Pure-TypeScript, zero-dependency engines for sports betting and DFS apps** — auditable pick'em settlement, sportsbook odds math, and transparent game-entertainment scoring. Every package is a set of pure functions: no I/O, no framework lock-in, no native deps. Feed data in, get deterministic, explainable decisions out — with validation reports and audit trails, because settling money on `if (points > line)` is how disputes happen. Built and used in production by Buzzr, a sports social app. +**Pure-TypeScript, zero-dependency engines for sports betting and DFS apps** — auditable pick'em settlement, sportsbook odds math, and transparent game-entertainment scoring. The three core engines are pure and perform no I/O; provider contracts inject data, while the CLI and MCP packages are thin boundary wrappers. Feed data in, get deterministic, explainable decisions out — with validation reports and audit trails, because settling money on `if (points > line)` is how disputes happen. The packages originated in Buzzr, a sports social app; the exact app integration snapshot is documented below. ## The packages @@ -14,15 +14,15 @@ | [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine) | DFS settlement OS: book policies, grading, payouts, audit trails, batch settlement | `npm i @buzzr/dfs-engine` | | [`@buzzr/bets-core`](https://www.npmjs.com/package/@buzzr/bets-core) | Odds math: no-vig fair lines, parlays, EV, Kelly staking, CLV, period analytics | `npm i @buzzr/bets-core` | | [`@buzzr/entertainment-engine`](https://www.npmjs.com/package/@buzzr/entertainment-engine) | Transparent buzz scoring, hybrid ML predictions, personalized game recommendations | `npm i @buzzr/entertainment-engine` | -| [`@buzzr/mcp`](https://www.npmjs.com/package/@buzzr/mcp) | MCP server exposing the engines to AI agents (8 tools) | `npx -y @buzzr/mcp` | +| [`@buzzr/mcp`](https://www.npmjs.com/package/@buzzr/mcp) | MCP server exposing the engines to AI agents (11 tools) | `npx -y @buzzr/mcp@5.1.0` | | [`@buzzr/dfs-cli`](https://www.npmjs.com/package/@buzzr/dfs-cli) | Grade a DFS entry from JSON on the command line | `npm i -g @buzzr/dfs-cli` | | [`@buzzr/dfs-react`](https://www.npmjs.com/package/@buzzr/dfs-react) | Settlement → UI view-models (React/Vue/Svelte/vanilla; no React dep) | `npm i @buzzr/dfs-react` | | [`@buzzr/dfs-testkit`](https://www.npmjs.com/package/@buzzr/dfs-testkit) | Fixture builders + mock stat providers for tests | `npm i -D @buzzr/dfs-testkit` | | [`@buzzr/dfs-provider-espn`](https://www.npmjs.com/package/@buzzr/dfs-provider-espn) | ESPN-shaped stat provider contract | `npm i @buzzr/dfs-provider-espn` | | [`@buzzr/dfs-provider-sportradar`](https://www.npmjs.com/package/@buzzr/dfs-provider-sportradar) | Sportradar-shaped stat provider contract | `npm i @buzzr/dfs-provider-sportradar` | -| [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) | Golden fixtures proving your integration grades identically to Buzzr's | `npm i -D @buzzr/dfs-engine-test-vectors` | +| [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) | Engine regression fixtures for the matching package version | `npm i -D @buzzr/dfs-engine-test-vectors` | -All packages: TypeScript-first with full `.d.ts`, ESM + CJS builds (the CLI is ESM-only), Node >= 22, MIT, zero runtime dependencies outside the family. +All packages are TypeScript-first with full `.d.ts`, Node >= 22, and MIT licensing. The core engines have zero external runtime dependencies and ship ESM + CJS; the CLI is ESM-only, and the MCP server necessarily depends on the official MCP SDK plus Zod. ## Architecture @@ -88,7 +88,9 @@ const result = await engine.settleEntry(entry, { statProviderId: 'my-stats' }); const batch = await engine.settleEntries(entries, { statProviderId: 'my-stats' }); ``` -Book policies for PrizePicks- and Underdog-style play types are built in and versioned; custom books plug in via `defineBookPolicy`, and v5 adds policy validation plus a draft prediction-market (Kalshi-style) policy. +Built-in operator-named policies are independent compatibility profiles, not official rules engines. PrizePicks is an experimental, partially verified profile; Underdog is experimental and unverified. The displayed lineup terms are authoritative. Custom books plug in via `defineBookPolicy`, and draft fixtures are not registered for settlement. + +The test-vector package publishes engine regression fixtures for the matching engine version. They are not official operator conformance. ### Price a bet — `@buzzr/bets-core` @@ -146,17 +148,29 @@ Add to your MCP client config (Claude Desktop, Claude Code, Cursor, …): "mcpServers": { "buzzr": { "command": "npx", - "args": ["-y", "@buzzr/mcp"] + "args": ["-y", "@buzzr/mcp@5.1.0"] } } } ``` -The server exposes the engines as 8 tools — settle entries, price parlays, compute EV/Kelly, score games — so agents get book-accurate math instead of hallucinated numbers. +The server exposes 11 tools for DFS validation and settlement, odds and bet-history math, and game scoring. It performs deterministic computation only; it does not fetch operator accounts, live odds, or box scores. See the [MCP install, client configuration, tool catalog, and error contracts](packages/mcp/README.md). + +## Verified Buzzr app integration + +The Buzzr mobile app’s `release/ios-2.0.0` branch vendors `@buzzr/bets-core`, `@buzzr/dfs-engine`, and `@buzzr/entertainment-engine` as local 5.0.0 tarballs and imports all three. That verified snapshot is not automatically upgraded to the public 5.1.0 toolkit; an app update remains a separate, deliberate release task. + +The live consumer is [Buzzr Sports on the App Store](https://apps.apple.com/us/app/buzzr-sports/id6760628256). -## Used in production by Buzzr +## Codex skill + +The repository-owned [Buzzr Sports Engine skill](skills/buzzr-sports-engine/SKILL.md) routes DFS, odds, history, and game-scoring work to the 11 MCP tools and records operator-safety limits. + +```sh +npx skills add https://github.com/Buzzr-app/dfs-engine --skill buzzr-sports-engine +``` -These packages are extracted from — and power — the Buzzr sports app: DFS slip grading, sportsbook bet tracking, and the buzz scores on every game card run through exactly this code. The app is the first consumer of every release, so the published API is the one we live with ourselves. +After installation, configure the local server with the [MCP client instructions](packages/mcp/README.md). Pin a reviewed published `@buzzr/mcp` version when repeatability matters. ## Development @@ -173,10 +187,9 @@ Before publishing or cutting a release, run: ```bash npm run verify -npm run audit:high ``` -`verify` runs typecheck, lint, format check, tests, coverage, build, docs, export smoke tests, package size checks, and a dry-run pack across all packages. +`verify` runs typecheck, lint, formatting, tests, coverage, build, packed-package and real-client proofs, the repository skill proof, API docs, public-doc and local-link contracts, export and package smoke checks, release-workflow and MCP Registry metadata checks, and the high-severity dependency audit. CI additionally checks external links on Node 22. ## Reporting bugs @@ -186,10 +199,16 @@ For settlement correctness or security-sensitive issues, follow [SECURITY.md](SE ## Links -- [API docs (typedoc)](https://buzzr-app.github.io/dfs-engine/) +- [Generated API docs for all ten packages (TypeDoc)](https://buzzr-app.github.io/dfs-engine/) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) - [AGENTS.md](AGENTS.md) — how AI coding agents should use this repo - [llms.txt](llms.txt) — machine-readable package index +- [Architecture and data flow](docs/architecture.md) — package layers and execution paths +- [Security, privacy, and threat model](docs/security-and-privacy.md) — trust boundaries and controls +- [Versioning, compatibility, and support](docs/versioning-and-support.md) — SemVer, migrations, and app separation +- [All-package API index](docs/api-reference.md) — supported roots for all ten packages +- [Buzzr Sports Engine skill](skills/buzzr-sports-engine/SKILL.md) — Codex workflow and safety contract +- [MCP configuration](packages/mcp/README.md) — install and client setup ## License diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..41fca72 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,118 @@ +# Package API index + +This page maps every published package to its supported root API and detailed package documentation. The package's `src/index.ts` is authoritative; deep `src/*` or `dist/*` imports are unsupported. + +The [generated TypeDoc site](https://buzzr-app.github.io/dfs-engine/) covers the supported root exports of all ten public packages. This hand-curated index adds package purpose and entry-point guidance; each package section links directly to its generated module reference. + +## Core engines + +### `@buzzr/dfs-engine` + +DFS validation, effective-dated compatibility/custom policies, stat providers, settlement, payout math, provenance, and audit records. + +- Primary runtime: `createDfsEngine`, `defineStatProvider`, `defineBookPolicy`, `definePayoutTable`, `validateBookPolicyDefinition`, `validateDfsEntryInput`, grading/payout helpers, league adapters, and migration adapters. +- Primary contracts: `DfsEntryInput`, `DfsLegInput`, `DfsSettlementContext`, `DfsSettlementResult`, batch result/cache types, policy/table/source/verification types, and provider interfaces. +- [README](../packages/dfs-engine/README.md) +- [Root exports](../packages/dfs-engine/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-engine.html) + +### `@buzzr/bets-core` + +Sportsbook odds, value, bankroll, and history analytics. + +- Primary runtime: `americanOddsToImpliedProbability`, `probabilityToAmericanOdds`, `calculateNoVigFairLine`, `combineAmericanOdds`, `calculateParlayProbability`, `calculateParlayFairValue`, `calculateExpectedValue`, `calculateKellyStake`, `calculateClosingLineValue`, `calculateBetRollup`, `calculateRollupByPeriod`, `calculateDrawdown`, `calculateStreaks`, `normalizeSportsbookSlug`, and `betRecordToDfsEntryInput`. +- [README](../packages/bets-core/README.md) +- [Root exports](../packages/bets-core/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_bets-core.html) + +### `@buzzr/entertainment-engine` + +Transparent entertainment scoring, feature extraction/training, model diagnostics, reporting, and personalized recommendations. + +- Primary runtime: `resolveBuzzScores`, `enrichGameRowWithBuzzScores`, `isMustWatch`, `predictGame`, `predictGameWithDiagnostics`, `trainSGD`, `validateModel`, `buildModelRunReport`, `rankGamesForUser`, and `explainRecommendation`. +- Primary constants/contracts: `MUST_WATCH_THRESHOLD`, model version/feature constants, score/model/report types, `UserAffinityProfile`, and recommendation types. +- [README](../packages/entertainment-engine/README.md) +- [Root exports](../packages/entertainment-engine/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_entertainment-engine.html) + +## Process boundaries + +### `@buzzr/mcp` + +Local stdio MCP server exposing 11 tools across the three core engines. + +- Server API: `createBuzzrMcpServer`, `registerBuzzrTool`, `allTools`, `SERVER_NAME`, and `SERVER_VERSION`. +- Tool groups: `dfsTools`, `oddsTools`, `historyTools`, and `buzzTools`, plus each named tool definition. +- Embedding helpers: `defineTool`, `jsonResult`, `errorResult`, and public tool/result types. +- Executables: `mcp` and `buzzr-mcp`. +- [README and 11-tool catalog](../packages/mcp/README.md) +- [Root exports](../packages/mcp/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_mcp.html) + +### `@buzzr/dfs-cli` + +Filesystem wrapper for grading one entry JSON document against a leg-keyed game-log JSON document. + +- Runtime: `runGrade` and `runGradeFromFiles`. +- Contracts: `GameLogsByLegId`, `RunGradeInput`, and `RunGradeFileInput`. +- Executable: `dfs-grade`. +- [README](../packages/dfs-cli/README.md) +- [Root exports](../packages/dfs-cli/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-cli.html) + +## Integration helpers + +### `@buzzr/dfs-react` + +Framework-neutral settlement display models; no React runtime dependency. + +- Runtime: `getSlipDisplayModel`, `getStatusTone`, `formatLegLabel`, and `formatLegLine`. +- Contracts: `SlipStatusTone`, `LegDisplayModel`, and `SlipDisplayModel`. +- [README](../packages/dfs-react/README.md) +- [Root exports](../packages/dfs-react/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-react.html) + +### `@buzzr/dfs-testkit` + +Fixture builders and injected mock providers for engine consumers. + +- Runtime: `makeDfsEntry`, `makeDfsLeg`, `makeGameLogEntry`, `createMockStatProvider`, and `makeInvalidDfsEntry`. +- [README](../packages/dfs-testkit/README.md) +- [Root exports](../packages/dfs-testkit/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-testkit.html) + +### `@buzzr/dfs-provider-espn` + +Adapter from a consumer-owned ESPN-shaped game-log loader to `StatProvider`. It performs no network request itself. + +- Runtime: `createEspnStatProvider`. +- Contracts: `EspnGameLogLoaderInput` and `EspnStatProviderOptions`. +- [README](../packages/dfs-provider-espn/README.md) +- [Root exports](../packages/dfs-provider-espn/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-provider-espn.html) + +### `@buzzr/dfs-provider-sportradar` + +Adapter from consumer-owned Sportradar-shaped basketball rows to `PlayerGameLogEntryShape` and `StatProvider`. It performs no network request itself. + +- Runtime: `createSportradarStatProvider` and `sportradarRowToGameLog`. +- Contracts: `SportradarBasketballStatLine`, `SportradarGameLogLoaderInput`, and `SportradarStatProviderOptions`. +- [README](../packages/dfs-provider-sportradar/README.md) +- [Root exports](../packages/dfs-provider-sportradar/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-provider-sportradar.html) + +### `@buzzr/dfs-engine-test-vectors` + +Versioned engine regression fixtures for the matching `@buzzr/dfs-engine` version. They are not official operator conformance. + +- Runtime: `TEST_VECTORS`. +- Contracts: `TestVector`, `ExpectedSettlement`, and `ExpectedLegOutcome`. +- [README](../packages/dfs-engine-test-vectors/README.md) +- [Root exports](../packages/dfs-engine-test-vectors/src/index.ts) +- [Generated TypeDoc](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-engine-test-vectors.html) + +## Machine-readable and agent references + +- [`llms.txt`](../llms.txt) for a compact package index. +- [`AGENTS.md`](../AGENTS.md) for repository invariants. +- [Buzzr Sports Engine skill](../skills/buzzr-sports-engine/SKILL.md) for MCP/package routing and operator safety. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b6008cb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,105 @@ +# Architecture and data flow + +Buzzr Sports Engines separates deterministic domain code from process and data-source boundaries. The repository contains ten packages, but only three are core engines. + +## Layers + +```mermaid +flowchart LR + Caller["Application or agent host"] + Data["Caller-owned data sources"] + + subgraph Boundary["Boundary packages"] + CLI["@buzzr/dfs-cli
JSON files"] + MCP["@buzzr/mcp
stdio JSON-RPC"] + ESPN["@buzzr/dfs-provider-espn
caller loader adapter"] + SR["@buzzr/dfs-provider-sportradar
caller loader adapter"] + React["@buzzr/dfs-react
view models"] + end + + subgraph Core["Pure core engines"] + DFS["@buzzr/dfs-engine"] + Bets["@buzzr/bets-core"] + Entertainment["@buzzr/entertainment-engine"] + end + + subgraph Test["Testing artifacts"] + Testkit["@buzzr/dfs-testkit"] + Vectors["@buzzr/dfs-engine-test-vectors"] + end + + Caller --> CLI + Caller --> MCP + Data --> ESPN + Data --> SR + CLI --> DFS + MCP --> DFS + MCP --> Bets + MCP --> Entertainment + ESPN --> DFS + SR --> DFS + DFS --> React + Testkit -.-> DFS + Vectors -.-> DFS +``` + +### Core engines + +- `@buzzr/dfs-engine` owns canonical DFS inputs, policy and payout definitions, stat extraction, validation, settlement, explanations, provenance, and audit records. +- `@buzzr/bets-core` owns sportsbook odds conversion, no-vig pricing, parlays, expected value, Kelly sizing, closing-line value, and history analytics. +- `@buzzr/entertainment-engine` owns transparent game-entertainment scoring, model diagnostics, training/reporting helpers, and personalized ranking. + +The core engines have zero external runtime dependencies and perform no network or filesystem I/O. New domain behavior belongs here when it can remain deterministic from supplied inputs. + +### Boundary packages + +- `@buzzr/mcp` is a local stdio process. The official MCP SDK handles JSON-RPC, Zod validates bounded tool inputs, and handlers call the core engines. Protocol messages use stdout; diagnostics use stderr. +- `@buzzr/dfs-cli` reads two JSON files and delegates settlement to `@buzzr/dfs-engine`. +- `@buzzr/dfs-provider-espn` and `@buzzr/dfs-provider-sportradar` adapt consumer-supplied loaders. They do not contain vendor API clients or credentials. +- `@buzzr/dfs-react` converts settlement results into framework-neutral display models. It does not depend on a React runtime. +- `@buzzr/dfs-testkit` builds fixtures and injected mock providers. +- `@buzzr/dfs-engine-test-vectors` publishes versioned engine regression fixtures. It is not an operator certification suite. + +## DFS settlement flow + +1. A caller builds a `DfsEntryInput` and supplies observed values directly or registers a `StatProvider`. +2. The engine validates the entry, selects a policy and effective-dated payout table from the entry's `placedAt` or deterministic settlement clock, and resolves each leg. +3. A provider path validates rows and matches the requested game date. Missing, ambiguous, invalid, and unsupported data remain explicit rather than silently selecting a value. +4. The selected policy handles caller-supplied statuses such as DNP, push, void, rescue, or cancellation. +5. The result includes payout, per-leg decisions, validation, policy/table metadata, verification, confidence, source references, provider provenance, explanation codes, pending reasons, and an ordered audit trail. + +Built-in operator-named policies are compatibility profiles. PrizePicks is experimental/partial and Underdog is experimental/unverified. Displayed entry terms and explicit operator rulings remain authoritative. + +## MCP flow + +```text +MCP client + -> newline-delimited JSON-RPC over stdin + -> 2 MiB frame guard + -> MCP SDK request-envelope handling + -> process-wide 32-call guard + -> bounded handler-owned Zod validation + -> one of 11 bounded tool handlers + -> core engine + -> at most 1 MiB serialized JSON text result + -> JSON-RPC over stdout +``` + +Invalid tool arguments use the same bounded `invalid_input` result over a real MCP transport and through direct exported handlers. Handler-owned validation prevents raw SDK/Zod issue lists from bypassing the result-size cap. Malformed JSON-RPC envelopes remain MCP SDK protocol errors. Tool runtime errors are generic and do not expose internal error messages. + +The MCP process computes only from supplied data. It does not fetch live odds, box scores, operator accounts, or private user data. + +## State and immutability + +Engine instances own policy, payout-table, provider, adapter, clock, and audit configuration. Public policy listings are immutable snapshots. Batch settlement owns a per-call cache and returns new result objects when adding cache-hit metadata; it does not mutate prior single-entry results. + +## Repository and app boundary + +This monorepo is the public package source of record. The Buzzr mobile app's `release/ios-2.0.0` branch is a separate consumer that currently vendors local 5.0.0 tarballs for `@buzzr/bets-core`, `@buzzr/dfs-engine`, and `@buzzr/entertainment-engine`. The public 5.1.0 toolkit does not update that app until a separate reviewed app release deliberately changes its vendored artifacts. + +## Related references + +- [All-package API index](api-reference.md) +- [Security, privacy, and threat model](security-and-privacy.md) +- [Versioning, compatibility, and support](versioning-and-support.md) +- [MCP install and contracts](../packages/mcp/README.md) diff --git a/docs/launch/adoption-baseline-2026-07-16.md b/docs/launch/adoption-baseline-2026-07-16.md new file mode 100644 index 0000000..d37e3f9 --- /dev/null +++ b/docs/launch/adoption-baseline-2026-07-16.md @@ -0,0 +1,91 @@ +# Adoption baseline — 2026-07-16 + +This is the pre-release baseline for the public toolkit work that follows v5.0.0. Use the same source and window definitions for the +7-day and +30-day comparisons. + +## Measurement clock + +Release day (day 0), written as **D**, is the UTC calendar date on which the +first reviewed 5.1.0 package is confirmed live on npm. The planned date is +2026-07-16, but it becomes D only after that live proof. Because publication +can happen partway through a day, day 0 is not included in either follow-up +download window. + +- Baseline: the fixed seven complete UTC days from 2026-07-09 through + 2026-07-15. +- +7: D+1 through D+7, captured on or after 00:00 UTC on D+8. +- +30: D+1 through D+30, captured on or after 00:00 UTC on D+31. + +Run the read-only capture with explicit inclusive dates. It writes +machine-readable JSON to standard output and does not change GitHub, npm, or +the MCP Registry: + +```bash +npm run --silent capture:adoption -- --start 2026-07-09 --end 2026-07-15 +``` + +## npm downloads + +Source: npm downloads API. Window: the seven complete UTC days from 2026-07-09 through 2026-07-15. + +Measured family baseline: 191 package downloads from 2026-07-09 through 2026-07-15. This is package activity, not a unique-user count. + +| Package | Downloads | +| ---------------------------------- | --------: | +| `@buzzr/dfs-engine` | 38 | +| `@buzzr/bets-core` | 34 | +| `@buzzr/dfs-provider-espn` | 25 | +| `@buzzr/entertainment-engine` | 20 | +| `@buzzr/dfs-testkit` | 19 | +| `@buzzr/dfs-provider-sportradar` | 18 | +| `@buzzr/dfs-engine-test-vectors` | 16 | +| `@buzzr/dfs-cli` | 9 | +| `@buzzr/dfs-react` | 8 | +| `@buzzr/mcp` | 4 | +| **Family total** | **191** | + +Package downloads overlap when one consumer installs several packages. Treat the family total as package-download volume, not unique users. + +`@buzzr/mcp` downloads are the privacy-preserving MCP adoption proxy. The +baseline is 4 package downloads. This aggregate does not identify MCP clients +or unique users, and it can include reinstalls, CI, cache misses, and direct npm +downloads that never start the server. + +## GitHub and discovery + +Snapshot captured from the GitHub and MCP Registry APIs on 2026-07-16. + +| Metric | Baseline | +| ------------------------------------------- | -------: | +| GitHub stars | 1 | +| GitHub forks | 0 | +| GitHub subscribers | 0 | +| GitHub clones, trailing 14 days | 98 | +| GitHub unique cloners, trailing 14 days | 37 | +| GitHub page views, trailing 14 days | 1 | +| GitHub unique viewers, trailing 14 days | 1 | +| MCP Registry records matching Buzzr | 0 | + +The clone window contains the v5.0.0 release day, when GitHub reports 74 clones and 26 unique cloners. Keep that launch spike visible rather than treating the 14-day total as a steady daily rate. + +GitHub's traffic endpoints always return a rolling 14-day repository window, +so their +7 and +30 snapshots are point-in-time rolling totals rather than +cumulative release windows. GitHub repo views and unique viewers are not docs +site analytics. GitHub Pages has no first-party analytics configured for this +site, so the baseline makes no claim about docs-site visits or uniques. + +Top referrers and top paths are also rolling GitHub repository traffic data. +Referrer attribution is a limited hint, not a complete referral funnel: only the +most popular reported sources appear, direct or unattributed traffic is not +resolved, npm exposes no referral source, and repository paths do not measure +Pages traffic. Use channel timing as correlation, never proof that a channel +caused an install. + +## Measurement protocol + +- Record +7 days and +30 days using complete UTC days only. +- Query all ten npm packages individually and retain both per-package and family totals. +- Record GitHub traffic, top referrers, and top paths before the API's rolling 14-day window expires. +- Separate GitHub issues from pull requests when measuring community reports. +- Record MCP Registry presence by exact server name after publication. +- Do not infer unique users by summing npm package downloads. +- Save the JSON capture alongside the launch evidence so every number retains its source URL, capture timestamp, and window semantics. diff --git a/docs/launch/awesome-lists.md b/docs/launch/awesome-lists.md index e2b18a0..2dd6eae 100644 --- a/docs/launch/awesome-lists.md +++ b/docs/launch/awesome-lists.md @@ -1,44 +1,44 @@ -# Awesome-list PR drafts +# Directory submission tracker -> Status: PREPARED TEXT ONLY — the maintainer submits each PR manually from their own GitHub account. Read each list's CONTRIBUTING.md first; most require the project to be >30 days old with real activity, and entries must match the list's exact formatting. Submit ONE list at a time and let a PR land before opening the next. +> Status: MANUAL RELEASE FOLLOW-UP. Do not publish or open duplicate submissions before the reviewed npm/GitHub release is live. Re-check each directory's current rules immediately before submission. -## 1. awesome-mcp-servers (highest fit — do this first) +## Official MCP Registry -- Repo: https://github.com/punkpeye/awesome-mcp-servers (also consider https://github.com/wong2/awesome-mcp-servers) -- Section: Sports (if present) or Finance/Developer Tools per the list's taxonomy -- Entry format follows the list's `[name](link) - description` style with emoji legend (📇 TypeScript, 🏠 local) +Publish `server.json` as `io.github.Buzzr-app/dfs-engine` with the official `mcp-publisher` after the matching `@buzzr/mcp` version and provenance are live. This is the authoritative MCP listing; verify the exact registry record and install metadata after publication. -Proposed entry text: +## Existing awesome-mcp-servers PR -``` -- [Buzzr Engines](https://github.com/Buzzr-app/dfs-engine) 📇 🏠 - DFS settlement, sportsbook odds math (no-vig lines, parlays, EV, Kelly), and game entertainment scoring as 8 tools backed by zero-dependency engines used in production. -``` - -## 2. awesome-typescript +- Directory: https://github.com/punkpeye/awesome-mcp-servers +- Existing submission: https://github.com/punkpeye/awesome-mcp-servers/pull/9540 +- Current state checked 2026-07-16: open; `check-submission` passes; the maintainer bot requires a Glama listing and score badge. +- Action after release: update the existing PR instead of opening a duplicate. Replace its retired tool-count and production-use claims, point to the live package/repository, and use bounded wording. -- Repo: https://github.com/dzharii/awesome-typescript -- Section: Built with TypeScript / Libraries (match existing section names) +Proposed corrected entry: -Proposed entry text: - -``` -- [@buzzr/dfs-engine](https://github.com/Buzzr-app/dfs-engine) — Zero-dependency, pure-functional DFS settlement engine: declarative book policies, grading, payout math, and audit trails, with published golden test vectors for conformance testing. +```text +- [Buzzr-app/dfs-engine](https://github.com/Buzzr-app/dfs-engine/tree/main/packages/mcp) 📇 🏠 - Local 11-tool server for DFS compatibility audits, sportsbook odds math, bet-history summaries, and game entertainment scoring. Install with `npx -y @buzzr/mcp`. ``` -## 3. awesome-sports-analytics +Before updating the PR, either complete the directory's current Glama requirement and append the exact score badge it provides, or close the PR with a short explanation. Do not invent a badge path. -- Repo: https://github.com/hkair/awesome-sports-analytics (verify the most active list before submitting; alternatives exist under similar names) -- Section: Tools / Open Source Projects +## Additional current directory -Proposed entry text: +- Directory: https://github.com/TensorBlock/awesome-mcp-servers +- State checked 2026-07-16: active, public, and accepts repository entries through pull requests. +- Action after the official registry and npm release are live: search for an existing Buzzr entry, follow the repository's then-current category/format rules, and submit only if it is not a duplicate. +Proposed entry: + +```text +- [Buzzr Sports Engines](https://github.com/Buzzr-app/dfs-engine) - Local TypeScript MCP server for bounded DFS compatibility analysis, sportsbook calculations, bet-history summaries, and game entertainment scoring. ``` -- [Buzzr Sports Engines](https://github.com/Buzzr-app/dfs-engine) - Open-source TypeScript engines for DFS settlement, betting odds math (no-vig fair lines, EV, Kelly, CLV), and game entertainment scoring. -``` -## Notes before submitting +## Not current PR targets + +- `dzharii/awesome-typescript` is archived. Its contribution policy also rejects AI-generated submissions; do not submit. +- `wong2/awesome-mcp-servers` is maintained through https://mcpservers.org rather than repository entry pull requests. Use its website submission flow only after the release is live. +- The previously drafted `hkair/awesome-sports-analytics` repository does not exist. It has been removed as a target. + +## Submission proof -- Check each list's alphabetical-order and description-length rules; trim the entry if the list caps at ~100 chars. -- Some lists run a linter (awesome-lint) on PRs — descriptions must end with a period and links must be HTTPS. -- If a list has both "TypeScript" and "Node.js" sections, pick one; double entries get PRs rejected. -- Optional later targets once traction exists: awesome-nodejs (bar is high), awesome-mcp (smaller variants), betting/DFS community wikis (r/algobetting sidebar). +For every live submission, record the URL, date, exact released version, directory requirements, status, and any follow-up requirement here. A prepared draft is not counted as a submitted or accepted listing. diff --git a/docs/launch/devto-article.md b/docs/launch/devto-article.md index 30b339e..8be3e81 100644 --- a/docs/launch/devto-article.md +++ b/docs/launch/devto-article.md @@ -17,13 +17,13 @@ Two years ago I shipped a sports app with a feature that grades DFS pick'em slip const won = actual > line; // what could go wrong ``` -Everything, it turns out. This article is about the architecture that replaced it: [`@buzzr/dfs-engine`](https://github.com/Buzzr-app/dfs-engine), a zero-dependency TypeScript settlement engine that now runs in production, and the design decisions that made it auditable rather than merely correct. +Everything, it turns out. This article is about the architecture that replaced it: [`@buzzr/dfs-engine`](https://github.com/Buzzr-app/dfs-engine), a zero-dependency TypeScript settlement engine, and the design decisions that made it auditable rather than merely correct. The Buzzr mobile app currently vendors the 5.0.0 tarballs for three engine packages; the public 5.1.0 toolkit is not automatically deployed to the app. ## Why settlement is harder than a comparison A pick'em entry seems trivial to grade until real slates happen: -- A player is ruled out pregame. PrizePicks doesn't void the slip — it removes the leg and **recalculates the multiplier** for the remaining legs. Underdog's rules differ. +- A player is ruled out pregame. Does the entry remove the leg, reprice, reboot, or void? The answer depends on the displayed entry terms and the operator's current ruling. - A player exits mid-game with 3 points on an o2.5 that already cleared. Is that a DNP or a win? Depends on the book and whether the stat was already banked. - The line is 26.0 and the player scores exactly 26. Push rules vary by book and play type. - A stat correction lands 36 hours after the game. Some leagues get corrections for days; you need a re-grade window per league. @@ -37,25 +37,43 @@ The core abstraction is the **book policy** — a declarative, versioned object ```ts const policy = defineBookPolicy({ - bookId: 'prizepicks', + id: 'my-book', + displayName: 'My Book', + version: '2026-07', + effectiveFrom: '2026-07-01', + status: 'experimental', + verification: { status: 'partial', reviewedAt: '2026-07-16' }, + sources: [{ label: 'Rules page', url: 'https://example.com/rules' }], playTypes: [ { - playTypeId: 'power', - payoutModel: 'fixed_table', - payoutTable: [ - /* legs → multiplier */ - ], - dnpPolicy: 'rescue_recalculate', // drop leg, recompute multiplier - pushPolicy: 'reduce_legs', - // ... + id: 'power', + displayName: 'Power', + payoutModel: 'fixed-table', + pickCount: { min: 2, max: 6 }, + allOrNothing: true, }, ], - sourceRefs: [{ label: 'PrizePicks payout and settlement compatibility profile' }], + tiePolicy: { type: 'push' }, + dnpPolicy: { type: 'remove_leg', voidIfNoSurvivors: true }, + pushPolicy: { type: 'remove_leg', refundIfNoSurvivors: true }, + payoutSplit: { type: 'all_withdrawable' }, + validation: { duplicatePlayers: 'warn' }, +}); + +const table = definePayoutTable({ + bookId: 'my-book', + playTypeId: 'power', + version: '2026-07', + effectiveFrom: '2026-07-01', + sources: [{ label: 'Rules page', url: 'https://example.com/rules' }], + entries: [{ pickCount: 2, hits: 2, multiplier: 3 }], }); ``` Policies are registered per engine instance (no global mutable registry), carry a `policyVersion`, and cite `sourceRefs` — so every settlement result can state *which rules, which version, based on what documentation* graded the slip. When a book changes its payout table, that's a new policy version, not a code archaeology expedition. +The bundled operator-named policies are independent compatibility profiles, not official engines. PrizePicks is experimental and partially verified: its standard payout references were reviewed on 2026-07-16 from [Payouts](https://www.prizepicks.com/help-center/payouts) and [Potential Outcomes](https://www.prizepicks.com/help-center/potential-outcomes), but settlement behavior and variable payouts remain incomplete. Underdog is experimental and unverified; the [legal center](https://legal.underdogsports.com/) is only the recorded rules entrypoint. The displayed entry terms remain authoritative. + Because policies are data, they're also validatable: v5 added `validateBookPolicyDefinition`, which rejects malformed policies (impossible payout tables, contradictory DNP rules) at definition time. A typo in a payout table should be a loud error, not a quiet 2% overpayment. ## Decision 2: the engine never fetches @@ -74,7 +92,7 @@ const result = await engine.settleEntry(entry, { statProviderId: 'my-stats' }); This bought three things: -1. **Zero runtime dependencies.** Settlement code is audit surface; every dependency is something a money-grading pipeline has to trust. The published packages depend on nothing outside the family. +1. **Zero runtime dependencies in the settlement engine.** Settlement code is audit surface; every dependency is something a money-grading pipeline has to trust. Boundary packages can still depend on the engine, and the MCP server uses the official MCP SDK plus Zod. 2. **Determinism in tests.** A mock provider is just a map from leg id to boxscore rows. 3. **Boundary validation.** Every row a provider returns is validated against the canonical `PlayerGameLogEntryShape` before grading. Malformed vendor data becomes an explicit `invalid_provider_data` failure instead of `NaN > 26.5 === false` silently grading a leg as lost. @@ -99,9 +117,9 @@ type LegGradingResult = When a user disputes a slip, the answer is in the settlement object — not in whatever logging happened to be enabled that night. -## Decision 4: conformance is a published package +## Decision 4: regression fixtures are a published package -The monorepo publishes its golden fixtures as [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors): verified `{ entry, gameLogsByLegId, expected }` triples. An integrator who wires their own stat pipeline replays the vectors in CI and proves their setup grades identically to production: +The monorepo publishes engine regression fixtures as [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors): verified `{ entry, gameLogsByLegId, expected }` triples. An integrator who wires a stat pipeline can replay the vectors in CI and detect drift from the matching engine version: ```ts for (const v of TEST_VECTORS) { @@ -110,7 +128,7 @@ for (const v of TEST_VECTORS) { } ``` -This converted a class of "works on my data" bug reports into a failing test on the integrator's side. It also acts as a behavioral ratchet on the engine itself: any change that flips a vector is by definition a breaking change, no debate needed. +This converted a class of "works on my data" bug reports into a failing test on the integrator's side. It also acts as a behavioral review gate on the engine itself: any change that flips a vector must be explained and versioned. The fixtures are not proof of current operator behavior. ## Decision 5 (v5): batch settlement without breaking purity @@ -122,7 +140,7 @@ const batch = await engine.settleEntries(entries, { concurrency: 4, }); // batch.summary: { total, settled, pending, failed } -// batch.cacheStats: { providerCalls, cacheHits } +// batch.cache: { providerCalls, cacheHits } ``` Two details I care about: in-flight **promises** are memoized (not just resolved values), so cache dedup holds at any concurrency; and the cache lives only for the call — no global state, purity preserved. One failed entry doesn't abort the batch; failures come back indexed in the result. @@ -134,9 +152,9 @@ If you're building anything that turns data into money decisions: 1. Make the rules **data** — versioned, validated, citable. 2. Make I/O someone else's job and **validate at the boundary**. 3. Return explanations as **typed values**; a settlement should be able to testify. -4. Publish your **golden fixtures**; conformance beats documentation. +4. Publish **engine regression fixtures**; executable examples catch integration drift. 5. Zero dependencies is a feature you can only choose early. -Everything above is MIT and on npm — the engine ([`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine)), odds math ([`@buzzr/bets-core`](https://www.npmjs.com/package/@buzzr/bets-core)), entertainment scoring ([`@buzzr/entertainment-engine`](https://www.npmjs.com/package/@buzzr/entertainment-engine)), an MCP server for AI agents ([`@buzzr/mcp`](https://www.npmjs.com/package/@buzzr/mcp)), plus CLI/testing/UI satellites. Monorepo and docs: https://github.com/Buzzr-app/dfs-engine · https://buzzr-app.github.io/dfs-engine/ +Everything above is MIT and on npm — the engine ([`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine)), odds math ([`@buzzr/bets-core`](https://www.npmjs.com/package/@buzzr/bets-core)), entertainment scoring ([`@buzzr/entertainment-engine`](https://www.npmjs.com/package/@buzzr/entertainment-engine)), an 11-tool MCP server for AI agents ([`@buzzr/mcp`](https://www.npmjs.com/package/@buzzr/mcp)), plus CLI/testing/UI satellites. Release 5.1.0 adds effective-dated policy provenance, adversarial regression vectors, bounded MCP contracts, and a repository-owned Codex skill. Monorepo and docs: https://github.com/Buzzr-app/dfs-engine · https://buzzr-app.github.io/dfs-engine/ -I'm one person building this for a production app, so issues and hostile code review are genuinely welcome. +I'm one person building this alongside a production app, so issues and hostile code review are genuinely welcome. diff --git a/docs/launch/launch-checklist.md b/docs/launch/launch-checklist.md index 9bad671..929003d 100644 --- a/docs/launch/launch-checklist.md +++ b/docs/launch/launch-checklist.md @@ -1,28 +1,33 @@ -# v5.0.0 launch checklist +# 5.1.0 public-toolkit launch checklist Sequenced end-to-end. Everything in `docs/launch/` is a draft the maintainer publishes manually — nothing here auto-posts. ## Phase 0 — Pre-flight (day −1) -- [ ] `npm run verify` green on `release/v5.0.0` (typecheck, lint, format, tests, coverage, build, docs, smoke, size, pack) +- [ ] `npm run verify` green at the reviewed release commit (typecheck, lint, format, tests, coverage, build, packed MCP, docs, smoke, size, pack, workflows, registry metadata, audit) - [ ] `npm run audit:high` clean +- [ ] `node scripts/check-public-docs.mjs` green - [ ] All 10 package READMEs render correctly on GitHub (badges, tables, code fences) - [ ] Root README, AGENTS.md, llms.txt merged to `main` - [ ] CHANGELOGs current for every package (changesets) -- [ ] Merge `release/v5.0.0` → `main` via PR +- [ ] Required CI is green and the reviewed PR is merged to protected `main` ## Phase 1 — Publish (day 0) -- [ ] `npm publish` all 10 packages (workspaces; verify publish order lets `@buzzr/dfs-engine` land before dependents, or use `--workspaces` with existing tooling) +- [ ] Publish only the five packages in the reviewed release manifest; publish `@buzzr/dfs-engine@5.1.0` before packages pinned to that version +- [ ] Verify every live npm artifact against the reviewed version, `gitHead`, exact integrity digest, registry tarball origin, and provenance attestation - [ ] Spot-check npm pages: README renders, keywords show, `repository`/`homepage` links resolve to the right package directory -- [ ] `npx -y @buzzr/mcp` starts clean on a machine that has never installed it +- [ ] The clean-cache published proof passes for the exact `@buzzr/mcp@5.1.0` on Linux, macOS, and Windows +- [ ] `npx -y @buzzr/mcp@5.1.0` starts clean with isolated npm/home state and a real MCP client lists all 11 tools - [ ] `npm i -g @buzzr/dfs-cli && dfs-grade --help` works +- [ ] `npx skills add https://github.com/Buzzr-app/dfs-engine --skill buzzr-sports-engine` discovers and installs the repository skill ## Phase 2 — GitHub release + docs (day 0) -- [ ] Tag `v5.0.0` and push the tag (this triggers the docs workflow → GitHub Pages) -- [ ] Verify https://buzzr-app.github.io/dfs-engine/ rebuilt with v5 API -- [ ] Write the GitHub Release: highlights (batch settlement, policy validation, parlay/EV/Kelly/CLV, calibrated ML + recommendations, new MCP server), migration notes, full changelog links +- [ ] Create the reviewed `v5.1.0` tag only after npm artifacts are live +- [ ] Verify https://buzzr-app.github.io/dfs-engine/ rebuilt from that exact tag +- [ ] Write the GitHub Release: highlights (effective-dated policy truthfulness, settlement fixes, adversarial vectors, 11-tool bounded MCP, Codex skill), migration notes, and full changelog links +- [ ] Publish and verify `io.github.Buzzr-app/dfs-engine` in the official MCP Registry after the npm version is live - [ ] Repo polish: description, website field → docs site, topics (`typescript`, `dfs`, `sports-betting`, `settlement`, `mcp`, `zero-dependency`, `prizepicks`, `underdog`) - [ ] Confirm the bug-report issue template still matches the README's "Reporting bugs" section @@ -44,15 +49,35 @@ Sequenced end-to-end. Everything in `docs/launch/` is a draft the maintainer pub ## Phase 5 — Sustain (week 2+) - [ ] Respond to every issue within 24h during launch window (responsiveness converts stars → users) -- [ ] Add "Powered by @buzzr open-source engines" section to the Buzzr app README (cross-promo) — done in the app repo +- [ ] Reverify the Buzzr app cross-link. Its `release/ios-2.0.0` branch currently vendors three 5.0.0 tarballs; upgrading it to the public 5.1.0 toolkit is a separate reviewed release decision - [ ] Watch npm download trends + GitHub traffic; note which channel converted for the next release - [ ] Follow-up content idea backlog: "How PrizePicks-style DNP rescue actually works", "Batch settlement cache design", "Giving AI agents real odds math via MCP" -## Metrics to record (baseline: ~24 downloads/week pre-launch) +## Metrics to record -| Metric | Baseline | +7 days | +30 days | -| ----------------------------------- | -------- | ------- | -------- | -| npm weekly downloads (family total) | ~24 | | | -| GitHub stars | | | | -| Issues/discussions opened | | | | -| Docs site uniques | | | | +Measured baseline: 191 package downloads from 2026-07-09 through 2026-07-15 +(complete UTC days). See +[the source snapshot](adoption-baseline-2026-07-16.md); package downloads are not +unique users. Release day (day 0) is D, the UTC date when the first reviewed +5.1.0 package is confirmed live. Capture +7 for D+1 through D+7 on or after D+8, +and +30 for D+1 through D+30 on or after D+31. Use +`npm run --silent capture:adoption -- --start YYYY-MM-DD --end YYYY-MM-DD` and retain its +JSON output as evidence. + +| Metric | Baseline | +7 days | +30 days | +| ------------------------------------------- | -------: | ------- | -------- | +| npm package downloads (family total) | 191 | | | +| `@buzzr/mcp` downloads (MCP adoption proxy) | 4 | | | +| GitHub stars | 1 | | | +| GitHub unique cloners (trailing 14 days) | 37 | | | +| GitHub repo page views (trailing 14 days) | 1 | | | +| GitHub repo unique viewers (trailing 14 days) | 1 | | | +| Issues opened (excluding pull requests) | 0 | | | +| MCP Registry records matching Buzzr | 0 | | | + +Also retain clones, top referrers, and top paths from each JSON snapshot. The +GitHub traffic values are rolling 14-day repository metrics, not cumulative +release windows. Referrers are incomplete attribution signals, and GitHub repo +views do not measure the Pages docs site. GitHub Pages has no first-party +analytics configured here, so do not report docs-site visits or uniques unless +a separate reviewed analytics source is added later. diff --git a/docs/launch/reddit.md b/docs/launch/reddit.md index 080255c..2096904 100644 --- a/docs/launch/reddit.md +++ b/docs/launch/reddit.md @@ -14,19 +14,19 @@ I've been building a sports app for a while and ended up extracting the "math th What's in it: -- `@buzzr/dfs-engine` — grades DFS pick'em entries (PrizePicks/Underdog-style) with declarative book policies: payout tables, DNP rescue rules, push/tie handling, boosts. Every settlement returns an audit trail + provenance of the stat rows used, so you can reconstruct exactly why a slip graded the way it did. v5 added batch settlement with a memoized stat cache. +- `@buzzr/dfs-engine` — grades DFS pick'em entries with declarative compatibility or custom policies: effective-dated payout tables, explicit DNP/push/tie inputs, validation, and boosts. Every settlement returns an audit trail, source metadata, verification status, and provider provenance. PrizePicks is experimental/partial; Underdog is experimental/unverified; the displayed entry terms remain authoritative. - `@buzzr/bets-core` — the odds toolbox: implied probability, no-vig fair lines from a two-way market, parlay pricing (`combineAmericanOdds`, fair-value vs offered), EV per bet, fractional Kelly staking, closing-line value, and bankroll analytics (period rollups, drawdown, streaks). -- Golden test vectors as a separate package — if you wire your own data pipeline, you can prove your grading matches production output. +- Engine regression fixtures as a separate package — if you wire your own data pipeline, you can detect drift from the matching engine version. They do not certify operator behavior. Design constraints: pure functions only, zero runtime dependencies, the engine never fetches — you inject your stat source and it validates rows at the boundary. -It's all MIT, runs in production in my app. Repo: https://github.com/Buzzr-app/dfs-engine — would genuinely value this sub picking holes in the Kelly/CLV implementations. +It's all MIT. The Buzzr mobile release branch currently vendors the 5.0.0 tarballs for three engine packages; the public 5.1.0 toolkit is a separate release until the app is deliberately upgraded. Repo: https://github.com/Buzzr-app/dfs-engine — would genuinely value this sub picking holes in the Kelly/CLV implementations. --- ## r/typescript -**Title:** Lessons from building a zero-dependency monorepo where the output is "money decisions" (DFS settlement engine, v5) +**Title:** Lessons from building a zero-dependency monorepo where the output is "money decisions" (DFS settlement engine 5.1) **Body:** @@ -35,7 +35,7 @@ I maintain a family of pure-TypeScript packages that grade DFS entries and price 1. **Result types over throws for explainability.** Grading functions have `*Explained` variants returning typed `{ ok, value | failure }` results with machine-readable failure reasons, because "why did this leg not grade" is a support question, not an exception. 2. **Policies as data.** Book behavior (payout tables, DNP rules, push handling) is a validated, versioned object — `defineBookPolicy` — not subclasses. Runtime validation of policy definitions shipped in v5 (`validateBookPolicyDefinition`) because a malformed policy is worse than a crash. 3. **Canonical shapes as frozen contracts.** All stat rows normalize to one `PlayerGameLogEntryShape`; vendor adapters (ESPN/Sportradar-shaped) map into it at the boundary, and the engine validates every row a provider returns. -4. **Golden vectors as a published package.** Conformance fixtures ship on npm so integrators' CI can prove behavioral compatibility — massively reduces "works on my data" bug reports. +4. **Regression vectors as a published package.** Versioned engine fixtures ship on npm so integrators' CI can detect drift from the matching library behavior — without claiming official operator equivalence. 5. **Zero runtime deps, ESM+CJS+d.ts via tsup**, strict tsconfig, Node >= 22. Repo (MIT): https://github.com/Buzzr-app/dfs-engine — happy to go deeper on any of the patterns. @@ -50,4 +50,4 @@ Repo (MIT): https://github.com/Buzzr-app/dfs-engine — happy to go deeper on an **Body:** -I open-sourced the math library my app uses for bet tracking, in case anyone here builds their own spreadsheets/tools: no-vig fair line from any two-way market, parlay fair value vs offered odds, per-bet EV, fractional Kelly stake sizing, and closing-line value. Also a DFS pick'em grader that mirrors PrizePicks/Underdog settlement rules (DNPs, pushes, boosts) if you want to audit how your slips settle. All free, MIT, no signup — it's a code library, so some technical comfort needed: https://github.com/Buzzr-app/dfs-engine +I open-sourced the math library my app uses for bet tracking, in case anyone here builds their own spreadsheets/tools: no-vig fair line from any two-way market, parlay fair value vs offered odds, per-bet EV, fractional Kelly stake sizing, and closing-line value. It also has an independent DFS compatibility grader with audit trails. Its PrizePicks profile is experimental/partial and its Underdog profile is experimental/unverified, so your displayed entry terms and operator ruling stay authoritative. All free, MIT, no signup — it's a code library, so some technical comfort is needed: https://github.com/Buzzr-app/dfs-engine diff --git a/docs/launch/show-hn.md b/docs/launch/show-hn.md index 0b8be82..3428f2a 100644 --- a/docs/launch/show-hn.md +++ b/docs/launch/show-hn.md @@ -21,11 +21,11 @@ So I extracted the settlement code into open-source packages and made "explain y - Book rules are data, not code: a `DfsBookPolicy` declares play types, payout tables, DNP/push/tie/rescue behavior, and is versioned so you can prove which rules graded a slip. - Every settlement returns a full audit trail, provider provenance (which stat source, what raw row), a validation report, and machine-readable explanation codes. - The engine does zero I/O. You inject a `StatProvider`; pure functions do the rest. Zero runtime dependencies, ESM+CJS, strict types. -- Golden test vectors ship as their own package, so an external integrator can prove their wiring grades identically to production. +- Versioned engine regression fixtures ship as their own package, so an external integrator can detect drift from the matching engine behavior. They are not operator certification. -v5.0.0 just landed: batch settlement with a memoized per-call stat cache, book-policy validation, and a draft prediction-market policy. There's also an odds-math package (no-vig fair lines, parlay pricing, EV, Kelly, CLV), an entertainment-scoring engine, and an MCP server so AI agents can call the real math instead of hallucinating payouts. +Release 5.1.0 adds effective-dated policy provenance, adversarial regression vectors, bounded public contracts, and a repository-owned Codex skill. There is also an odds-math package (no-vig fair lines, parlay pricing, EV, Kelly, CLV), an entertainment-scoring engine, and an 11-tool MCP server so AI agents can call deterministic math from supplied data. -Everything runs in production in the Buzzr app; the app is the first consumer of every release. +The Buzzr mobile app's `release/ios-2.0.0` branch currently vendors 5.0.0 tarballs for the DFS, odds, and entertainment engines. The app is not automatically upgraded to the public 5.1.0 toolkit. Repo: https://github.com/Buzzr-app/dfs-engine Docs: https://buzzr-app.github.io/dfs-engine/ @@ -37,4 +37,4 @@ Happy to answer questions about settlement edge cases — the DNP/rescue matrix - Why zero dependencies: settlement code is audit-surface; every dep is something a money-grading pipeline has to trust. - Why "engine + injected providers": books/apps have wildly different data sources; the engine validates rows at the boundary and refuses malformed data (`invalid_provider_data`) instead of mis-grading. -- Honest limitations: built-in policies cover PrizePicks/Underdog-style play types; prediction-market policy is a draft; provider packages are contracts, not API clients — you bring the fetch. +- Honest limitations: PrizePicks is an experimental/partial compatibility profile; Underdog is experimental/unverified; displayed entry terms and operator rulings remain authoritative. Draft fixtures are non-executable, and provider packages are contracts, not API clients — you bring the fetch. diff --git a/docs/launch/twitter-thread.md b/docs/launch/twitter-thread.md index e46884f..3f77195 100644 --- a/docs/launch/twitter-thread.md +++ b/docs/launch/twitter-thread.md @@ -16,7 +16,7 @@ github.com/Buzzr-app/dfs-engine 🧵 **2/** The problem: every book settles differently. -Player ruled out? PrizePicks drops the leg and RECALCULATES your multiplier. Exact-line push? Depends on the play type. Stat correction 36h later? Re-grade window varies by league. +Player ruled out? Does the entry remove the leg, reboot it, reprice, or void? Exact-line push? Which displayed terms apply? Stat correction 36h later? What is the re-grade window? These rules move real money. `if` statements don't scale to that. @@ -27,7 +27,7 @@ So in @buzzr/dfs-engine, book rules are DATA, not code: - versioned + source-cited - validated at definition time (v5) -When a user disputes a grade, you can prove exactly which rules ran. +When a user disputes a grade, you can show exactly which compatibility or custom policy ran. PrizePicks is experimental/partial; Underdog is experimental/unverified. The displayed entry still controls. **4/** The engine does zero I/O. You inject a StatProvider; it validates every row at the boundary. @@ -48,22 +48,26 @@ The result object can testify. **6/** And you don't have to trust me: golden fixtures ship as their own npm package (@buzzr/dfs-engine-test-vectors). -Replay them in your CI → prove your integration grades identically to production. +Replay them in your CI → detect drift from the matching engine version. + +They are regression fixtures, not official operator certification. **7/** -v5.0.0 just shipped across the family: +The 5.1.0 public-toolkit release includes: - settleEntries: batch settlement w/ memoized stat cache - book-policy validation - @buzzr/bets-core: parlay math, EV, Kelly staking, CLV - @buzzr/entertainment-engine: calibrated ML + game recommendations -- NEW @buzzr/mcp: the engines as MCP tools for AI agents +- @buzzr/mcp: 11 bounded tools for AI agents +- effective-dated policy sources + verification metadata +- a repository-owned Codex skill **8/** -That last one matters: point your agent at `npx -y @buzzr/mcp` and it prices parlays and settles entries with book-accurate math instead of hallucinating payouts. +That last one matters: point your agent at `npx -y @buzzr/mcp` and it runs deterministic odds, history, DFS compatibility, and game-scoring math on supplied data. It does not fetch live odds, box scores, or operator rulings. **9/** -All MIT, Node ≥22, ESM+CJS, typed to the teeth. Built by one person, running in production in the Buzzr app. +All MIT, Node ≥22, ESM+CJS, typed to the teeth. Built by one person. Buzzr's mobile release branch currently vendors the three 5.0.0 engine tarballs; the public 5.1.0 toolkit is not an automatic app upgrade. ⭐ github.com/Buzzr-app/dfs-engine 📚 buzzr-app.github.io/dfs-engine @@ -73,4 +77,4 @@ Questions about settlement edge cases welcome — the DNP matrix is cursed. ## Standalone short post (alternative, single tweet) -I open-sourced my sports app's money-grading code: a zero-dependency TypeScript DFS settlement engine with declarative book policies, boundary-validated stat providers, audit trails on every result, and published conformance vectors. v5 adds batch settlement + an MCP server for AI agents. github.com/Buzzr-app/dfs-engine +I open-sourced my sports app's settlement core: a zero-dependency TypeScript DFS engine with declarative compatibility policies, boundary-validated stat providers, audit trails, versioned regression fixtures, and an 11-tool MCP server. Operator-named profiles disclose verification limits. github.com/Buzzr-app/dfs-engine diff --git a/docs/security-and-privacy.md b/docs/security-and-privacy.md new file mode 100644 index 0000000..835b6f9 --- /dev/null +++ b/docs/security-and-privacy.md @@ -0,0 +1,79 @@ +# Security, privacy, and threat model + +This document defines the public package and local MCP security boundary. It is not a claim that every application embedding these libraries is secure. + +## Assets + +- Correct settlement, payout, odds, and ranking results. +- Integrity of the policy/table version and source metadata used for a decision. +- Availability of the local MCP process. +- Confidentiality of entries, bet history, identifiers, provider rows, and host environment data. +- Integrity and provenance of published npm and GitHub release artifacts. + +## Trust boundaries + +| Boundary | Trusted responsibility | Untrusted or caller-controlled data | +| --- | --- | --- | +| Application → core engine | Package code and selected configuration | Entry JSON, odds, bankroll values, stat rows, custom policies/providers | +| Data loader → `StatProvider` | Provider contract and runtime validator | Network/vendor response mapped by the caller | +| MCP client → stdio server | Packed server artifact and MCP SDK | JSON-RPC frames, strings, arrays, IDs, odds, entries, game/history data | +| npm/GitHub → local install | Reviewed release workflow, digest, `gitHead`, provenance | Registry/network delivery until verified | +| Engine result → user decision | Deterministic output for the supplied profile and data | Operator rules, displayed terms, and real-world rulings outside the engine | + +The core engines trust neither caller input nor operator-named compatibility data as proof of a real operator outcome. Validate at system boundaries and retain the result's verification and provenance fields. + +## Threats and controls + +### Untrusted input and resource exhaustion + +Runtime schemas reject non-finite numbers, invalid American odds, oversized strings and arrays, duplicate IDs where prohibited, and malformed DFS entries. The MCP stdio path rejects newline-delimited frames over 2 MiB, limits the process to 32 in-flight calls, caps serialized tool results at 1 MiB, and bounds validation detail. + +These controls reduce accidental and opportunistic resource abuse; they are not a substitute for OS-level process isolation when accepting hostile multi-tenant traffic. The shipped transport is local stdio, not an authenticated network service. + +### Error and protocol leakage + +MCP protocol output is written to stdout and diagnostics are written to stderr. Public execution failures use generic error codes/messages; internal exception names, stack traces, paths, and raw error messages are not returned to tool callers. Invalid tool arguments return the same bounded `invalid_input` detail through real MCP transports and direct handlers, preventing SDK-generated validation text from bypassing response limits. + +Do not add ordinary logs to stdout. They corrupt the stdio protocol and can expose host information. + +### Data and privacy + +The MCP server does not fetch live odds, box scores, operator accounts, or private user data. It requires no API key and has no application telemetry or persistence code. Inputs and results travel through the caller's local MCP host. + +That does not make supplied data anonymous: + +- DFS results can include provider provenance and raw supplied rows. +- History tools receive bet IDs, timestamps, stakes, payouts, and odds. +- The MCP host, terminal, crash reporter, or surrounding application may retain stdin, stdout, or stderr. + +Use synthetic or minimized identifiers when possible. Do not send credentials, access tokens, account cookies, health data, or unrelated personal data. If an embedding application persists results, its privacy policy and access controls govern that copy. + +### Prompt injection and command execution + +The 11 tools expose fixed schemas and deterministic calculations. User strings are treated as data and are not executed as shell commands, file paths, URLs to fetch, or prompts for a model. The server itself has no filesystem or network tool. + +An MCP host can expose other capabilities in the same session. Treat text echoed from entries or history as untrusted display content and do not pass it into a separate command/tool without that tool's own validation. + +### Supply chain + +Release proof must bind an npm artifact to the reviewed exact version, SHA-512 integrity, registry tarball origin, Git `gitHead`, and provenance attestation. CI uses packed-artifact real-client tests before publication, and the published proof runs in isolated temporary npm/home state with lifecycle scripts disabled. + +Run `npm run audit:high` and `npm run verify` before release. An audit result is one signal, not proof that dependencies or the registry are uncompromised. + +### Operator and financial risk + +PrizePicks and Underdog are independent compatibility profiles, not official rules engines. PrizePicks is experimental/partial; Underdog is experimental/unverified; draft fixtures are non-executable. Displayed entry terms and explicit operator rulings are authoritative. + +Odds, expected-value, Kelly, and settlement outputs are calculations from supplied assumptions. They are not financial, gambling, or legal advice and do not guarantee a payout. + +## Deployment guidance + +- Keep MCP on local stdio unless an embedding application adds its own authentication, authorization, rate limiting, TLS, request isolation, and audit policy. +- Pin `@buzzr/mcp@5.1.0` when repeatability matters. +- Run the server as a non-privileged user with the smallest environment and filesystem access the host permits. +- Do not place secrets in MCP configuration because Buzzr needs none. +- Review custom policy and provider code as trusted executable application code. + +## Reporting + +Report vulnerabilities and settlement-correctness issues through [`SECURITY.md`](../SECURITY.md). Avoid placing secrets, private slips, or exploit details in public issues. diff --git a/docs/versioning-and-support.md b/docs/versioning-and-support.md new file mode 100644 index 0000000..454513c --- /dev/null +++ b/docs/versioning-and-support.md @@ -0,0 +1,59 @@ +# Versioning, compatibility, and support + +Buzzr uses SemVer and Changesets for independent package releases. The 2026-07-16 public-toolkit train releases `@buzzr/dfs-engine`, `@buzzr/dfs-engine-test-vectors`, and `@buzzr/mcp` at 5.1.0, with 5.0.1 patches for `@buzzr/dfs-cli` and `@buzzr/dfs-testkit`. There is no lockstep-versioning promise: each release bumps the smallest set of packages whose public API, behavior, dependency pins, or published artifacts changed. + +## Release selection + +- **Patch:** compatible correctness, security, documentation, source/provenance, or payout-table corrections that do not add a public contract. +- **Minor:** additive exports, optional result fields, tools, supported cases, or fixture coverage. +- **Major:** removed or renamed exports, changed required fields, incompatible canonical shapes, or intentionally incompatible behavior. + +A release may need several independent package changes. For example, additive engine metadata plus new MCP tools and expanded vector fields can justify separate minor changes, while a dependent package with only an updated internal pin may need a patch. Record the decision in `.changeset/*.md`; do not infer a version from a draft document. + +The Changesets configuration has no `fixed` or `linked` groups. `updateInternalDependencies: "patch"` governs generated dependency updates, not a requirement to publish every workspace. + +## Compatibility contracts + +- Node >= 22 is the supported runtime floor; CI covers Node 22 and 24. +- Only each package root export is supported. Deep imports from `src` or `dist` are internal. +- Core libraries publish ESM, CJS, and types where their manifest declares them. `@buzzr/dfs-cli` is ESM-only and also exposes the `dfs-grade` executable. +- `DfsEntryInput`, `DfsLegInput`, `PlayerGameLogEntryShape`, and documented settlement result fields follow SemVer. Use published adapters instead of passing legacy aliases into strict validators. +- Operator policy and payout data is effective-dated. A correction can change which table a later `placedAt` selects without rewriting an older historical table. +- `grade_dfs_entries`, `closing_line_value`, and `summarize_bet_history` currently return string `contractVersion: "1"`. A consumer should reject an unknown contract version rather than guessing. +- Engine regression fixtures are tied to a matching `@buzzr/dfs-engine` version. They are not official operator conformance. + +## Deprecation and migration + +Breaking changes require a major version and migration notes. When practical, a deprecated adapter remains available through at least the next minor line before removal; settlement/security flaws can require faster action and will be called out explicitly. + +For every affected package: + +1. Add a Changeset describing user-visible impact. +2. Update the package `CHANGELOG.md` and README without inventing the final version before Changesets applies it. +3. Add or update tests and versioned regression fixtures. +4. Document renamed fields, changed defaults, status/explanation codes, and required caller actions. +5. Prove the packed artifacts, then verify the exact published version, integrity, `gitHead`, and provenance. + +Release documentation uses exact package versions only after Changesets has generated and the repository has verified the final manifests. + +## Application compatibility + +The public monorepo and Buzzr mobile app are separate release surfaces. The app's `release/ios-2.0.0` branch currently vendors local 5.0.0 tarballs for `@buzzr/bets-core`, `@buzzr/dfs-engine`, and `@buzzr/entertainment-engine`. A public npm release does not update those files automatically. Any mobile migration needs its own dependency change, tests, build, and release decision, while unrelated app-branch work remains untouched. + +For MCP clients, pin `@buzzr/mcp@5.1.0` when reproducibility matters. Using `@latest` opts into future compatible updates and should be a conscious host policy. + +## Support policy + +Security and settlement-correctness fixes target the latest major release first. Older majors may receive a low-risk backport when it does not change public contracts; no fixed backport duration or end-of-life schedule is currently promised. See [`SECURITY.md`](../SECURITY.md) for private reporting. + +## Release proof + +Before publishing: + +```sh +npm run verify +node scripts/check-public-docs.mjs +git diff --check +``` + +The release is not complete until CI, npm artifacts, the Git tag/GitHub Release, generated docs, registry metadata, and relevant cross-links all resolve to the reviewed commit. diff --git a/llms.txt b/llms.txt index bf07356..072ca28 100644 --- a/llms.txt +++ b/llms.txt @@ -1,15 +1,15 @@ # Buzzr Sports Engines -> Pure-TypeScript, zero-dependency npm packages (@buzzr/*) for sports betting and DFS apps: auditable pick'em settlement (book policies, grading, payouts, audit trails), sportsbook odds math (no-vig fair lines, parlays, EV, Kelly, CLV), transparent game-entertainment scoring with hybrid ML, and an MCP server exposing it all to AI agents. ESM+CJS+d.ts, Node >= 22, MIT. Monorepo: https://github.com/Buzzr-app/dfs-engine +> Pure-TypeScript sports engines and explicit boundary packages (@buzzr/*) for sports betting and DFS apps: auditable pick'em settlement (book policies, grading, payouts, audit trails), sportsbook odds math (no-vig fair lines, parlays, EV, Kelly, CLV), transparent game-entertainment scoring with hybrid ML, and an MCP server exposing it all to AI agents. The three core engines have zero external runtime dependencies; boundary packages declare the dependencies required by their I/O contract. Core libraries ship ESM+CJS+d.ts, the CLI and MCP packages are ESM, and every package supports Node >= 22 under MIT. Monorepo: https://github.com/Buzzr-app/dfs-engine Key facts: all engine code is pure functions (no I/O; data providers are injected). Canonical interchange shapes: DfsEntryInput, DfsLegInput, PlayerGameLogEntryShape (stat fields are strings, e.g. points: "31"). Settlement results include validation, provenance, auditTrail, and explanationCodes. Public API per package = its root export only. ## Core packages -- [@buzzr/dfs-engine](https://www.npmjs.com/package/@buzzr/dfs-engine): DFS settlement OS. Exports: createDfsEngine, defineStatProvider, defineBookPolicy, definePayoutTable, engine.settleEntry, engine.settleEntries (v5 batch with memoized stat cache), grading + payout + validation helpers. Built-in PrizePicks/Underdog-style book policies; v5 adds policy validation and a draft prediction-market policy. +- [@buzzr/dfs-engine](https://www.npmjs.com/package/@buzzr/dfs-engine): DFS settlement OS. Exports: createDfsEngine, defineStatProvider, defineBookPolicy, definePayoutTable, engine.settleEntry, engine.settleEntries (v5 batch with memoized stat cache), grading + payout + validation helpers. PrizePicks is an experimental, partially verified compatibility profile; Underdog is experimental and unverified. Displayed lineup terms are authoritative. Draft fixtures are not registered for settlement. - [@buzzr/bets-core](https://www.npmjs.com/package/@buzzr/bets-core): Odds math. Exports: americanOddsToImpliedProbability, calculateNoVigFairLine, combineAmericanOdds, calculateParlayProbability, calculateParlayFairValue, calculateExpectedValue, calculateKellyStake, calculateClosingLineValue, calculateBetRollup, calculateRollupByPeriod, calculateDrawdown, calculateStreaks. - [@buzzr/entertainment-engine](https://www.npmjs.com/package/@buzzr/entertainment-engine): Buzz scoring + predictions. Exports: resolveBuzzScores, enrichGameRowWithBuzzScores, isMustWatch, MUST_WATCH_THRESHOLD, rankGamesForUser (v5 personalized recommendations, calibrated ML confidence). -- [@buzzr/mcp](https://www.npmjs.com/package/@buzzr/mcp): MCP server (bin: buzzr-mcp; run: npx -y @buzzr/mcp) exposing the three engines as 8 tools for AI agents. +- [@buzzr/mcp](https://www.npmjs.com/package/@buzzr/mcp): MCP server (bin: buzzr-mcp; run: npx -y @buzzr/mcp@5.1.0) exposing the three engines as 11 tools for AI agents. It computes from supplied data and does not fetch live odds, box scores, operator accounts, or private user data. ## Satellite packages @@ -18,14 +18,30 @@ Key facts: all engine code is pure functions (no I/O; data providers are injecte - [@buzzr/dfs-testkit](https://www.npmjs.com/package/@buzzr/dfs-testkit): test fixtures + mocks; exports makeDfsEntry, makeDfsLeg, makeGameLogEntry, createMockStatProvider, makeInvalidDfsEntry. - [@buzzr/dfs-provider-espn](https://www.npmjs.com/package/@buzzr/dfs-provider-espn): ESPN-shaped StatProvider contract; exports createEspnStatProvider. No network I/O of its own. - [@buzzr/dfs-provider-sportradar](https://www.npmjs.com/package/@buzzr/dfs-provider-sportradar): Sportradar-shaped StatProvider contract; exports createSportradarStatProvider, sportradarRowToGameLog. No network I/O of its own. -- [@buzzr/dfs-engine-test-vectors](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors): golden { entry, gameLogsByLegId, expected } conformance fixtures; exports TEST_VECTORS. +- [@buzzr/dfs-engine-test-vectors](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors): engine regression fixtures for a matching @buzzr/dfs-engine version; exports TEST_VECTORS. They are not official operator conformance. + +## Verified Buzzr app integration + +The Buzzr mobile app’s `release/ios-2.0.0` branch vendors `@buzzr/bets-core`, `@buzzr/dfs-engine`, and `@buzzr/entertainment-engine` as local 5.0.0 tarballs and imports all three. That app snapshot is not automatically upgraded to the public 5.1.0 toolkit. + +Live app: [Buzzr Sports on the App Store](https://apps.apple.com/us/app/buzzr-sports/id6760628256). + +## Agent integration + +- Repository skill: [skills/buzzr-sports-engine/SKILL.md](skills/buzzr-sports-engine/SKILL.md) +- Install: `npx skills add https://github.com/Buzzr-app/dfs-engine --skill buzzr-sports-engine` +- MCP install, client configuration, tools, and errors: [packages/mcp/README.md](packages/mcp/README.md) ## Docs -- [API reference (typedoc)](https://buzzr-app.github.io/dfs-engine/): generated from @buzzr/dfs-engine. +- [API reference (TypeDoc)](https://buzzr-app.github.io/dfs-engine/): generated from the supported root exports of all ten public packages. - [Monorepo README](https://github.com/Buzzr-app/dfs-engine/blob/main/README.md): family table, architecture diagram, quick starts. -- [AGENTS.md](https://github.com/Buzzr-app/dfs-engine/blob/main/AGENTS.md): invariants and workflow for AI coding agents (zero deps, frozen v4 shapes, verify gate). +- [AGENTS.md](https://github.com/Buzzr-app/dfs-engine/blob/main/AGENTS.md): invariants and workflow for AI coding agents (dependency boundaries, frozen v4 shapes, verify gate). - [SECURITY.md](https://github.com/Buzzr-app/dfs-engine/blob/main/SECURITY.md): responsible disclosure for settlement-correctness issues. +- [Architecture and data flow](docs/architecture.md): pure cores, boundary packages, provider injection, MCP flow, and app/repo separation. +- [Security, privacy, and threat model](docs/security-and-privacy.md): trust boundaries, untrusted-input controls, data handling, supply-chain proof, and non-goals. +- [Versioning, compatibility, and support](docs/versioning-and-support.md): independent package SemVer, Changesets, migrations, runtime support, and mobile-app compatibility. +- [All-package API index](docs/api-reference.md): supported root APIs and detailed references for all ten packages. ## Optional diff --git a/package-lock.json b/package-lock.json index 9858ef3..18a525b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "buzzr-dfs-settlement-os", - "version": "5.0.0", + "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "buzzr-dfs-settlement-os", - "version": "5.0.0", + "version": "5.1.0", "license": "MIT", "workspaces": [ "packages/*" @@ -15,8 +15,11 @@ "@changesets/cli": "^2.31.0", "@types/node": "^22.19.19", "@vitest/coverage-v8": "^4.1.7", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", "eslint": "^10.4.0", "prettier": "^3.8.3", + "skills": "1.5.17", "tsup": "^8.5.1", "typedoc": "^0.28.19", "typescript": "^5.9.3", @@ -1259,28 +1262,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -2506,16 +2487,15 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2539,28 +2519,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -3223,6 +3181,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3253,6 +3228,13 @@ "node": ">=10.13.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4105,10 +4087,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-schema-typed": { @@ -5605,6 +5586,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/skills": { + "version": "1.5.17", + "resolved": "https://registry.npmjs.org/skills/-/skills-1.5.17.tgz", + "integrity": "sha512-Mi8P0sy/4rLYESPTCw4tso1hj87zpD3KRVg1iFUlLby2V0+SNAyWNHo/Gq9cruNww8oCSjB9S2hIWtUbVnklcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.3" + }, + "bin": { + "add-skill": "bin/cli.mjs", + "skills": "bin/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6430,10 +6428,10 @@ }, "packages/dfs-cli": { "name": "@buzzr/dfs-cli", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": "^5.0.0" + "@buzzr/dfs-engine": "^5.1.0" }, "bin": { "dfs-grade": "dist/cli.js" @@ -6444,7 +6442,7 @@ }, "packages/dfs-engine": { "name": "@buzzr/dfs-engine", - "version": "5.0.0", + "version": "5.1.0", "license": "MIT", "engines": { "node": ">=22" @@ -6452,10 +6450,10 @@ }, "packages/dfs-engine-test-vectors": { "name": "@buzzr/dfs-engine-test-vectors", - "version": "5.0.0", + "version": "5.1.0", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": "^5.0.0" + "@buzzr/dfs-engine": "5.1.0" }, "engines": { "node": ">=22" @@ -6496,10 +6494,10 @@ }, "packages/dfs-testkit": { "name": "@buzzr/dfs-testkit", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "dependencies": { - "@buzzr/dfs-engine": "^5.0.0" + "@buzzr/dfs-engine": "^5.1.0" }, "engines": { "node": ">=22" @@ -6515,17 +6513,18 @@ }, "packages/mcp": { "name": "@buzzr/mcp", - "version": "5.0.0", + "version": "5.1.0", "license": "MIT", "dependencies": { - "@buzzr/bets-core": "^5.0.0", - "@buzzr/dfs-engine": "^5.0.0", - "@buzzr/entertainment-engine": "^5.0.0", + "@buzzr/bets-core": "5.0.0", + "@buzzr/dfs-engine": "5.1.0", + "@buzzr/entertainment-engine": "5.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, "bin": { - "buzzr-mcp": "dist/cli.js" + "buzzr-mcp": "dist/cli.js", + "mcp": "dist/cli.js" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index 13a64c8..bb334c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "buzzr-dfs-settlement-os", - "version": "5.0.0", + "version": "5.1.0", "private": true, "description": "Monorepo for Buzzr DFS Settlement OS packages.", "license": "MIT", @@ -19,20 +19,38 @@ "lint": "eslint packages/*/src packages/*/tests", "format": "prettier --write \"packages/*/{src,tests,examples}/**/*.{ts,json,md}\" \"*.{json,md}\"", "format:check": "prettier --check \"packages/*/{src,tests,examples}/**/*.{ts,json,md}\" \"*.{json,md}\"", - "docs": "npm run docs --workspace @buzzr/dfs-engine", + "docs": "typedoc --options typedoc.json && node scripts/check-api-docs.mjs", "bench": "npm run bench --workspace @buzzr/dfs-engine", "smoke:exports": "node scripts/smoke-exports.mjs", "size:check": "node scripts/check-package-size.mjs", "audit:high": "npm audit --audit-level=high", - "verify": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:coverage && npm run build && npm run docs && npm run smoke:exports && npm run size:check && npm run pack:smoke", - "pack:smoke": "npm pack --workspaces --dry-run" + "check:workflows": "node scripts/check-release-workflows.mjs", + "test:release-supply-chain": "node --test tests/release-supply-chain.test.mjs", + "check:mcp-registry": "node scripts/check-mcp-registry-metadata.mjs", + "check:api-docs": "node scripts/check-api-docs.mjs", + "check:docs": "node scripts/check-public-docs.mjs", + "check:links": "node scripts/check-doc-links.mjs", + "check:links:external": "node scripts/check-doc-links.mjs --external", + "check:mcp:client-skill": "node scripts/check-mcp-client-skill-contracts.mjs", + "check:mcp:examples": "node scripts/validate-mcp-examples.mjs", + "check:skill": "node scripts/validate-buzzr-skill.mjs", + "capture:adoption": "node scripts/capture-adoption-metrics.mjs", + "test:adoption-capture": "node --test scripts/tests/capture-adoption-metrics.test.mjs", + "verify": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:coverage && npm run build && npm run test:mcp:packed && npm run test:packages:packed && npm run check:mcp:client-skill && npm run check:mcp:examples && npm run check:skill && npm run docs && npm run check:docs && npm run check:links && npm run smoke:exports && npm run size:check && npm run pack:smoke && npm run check:workflows && npm run test:release-supply-chain && npm run check:mcp-registry && npm run audit:high", + "pack:smoke": "npm pack --workspaces --dry-run", + "test:mcp:packed": "npm run build --workspace @buzzr/bets-core --workspace @buzzr/dfs-engine --workspace @buzzr/entertainment-engine --workspace @buzzr/mcp && node scripts/test-mcp-packed.mjs", + "test:packages:packed": "node scripts/test-release-artifacts.mjs", + "proof:mcp:published": "node scripts/prove-mcp-published.mjs" }, "devDependencies": { "@changesets/cli": "^2.31.0", "@types/node": "^22.19.19", "@vitest/coverage-v8": "^4.1.7", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", "eslint": "^10.4.0", "prettier": "^3.8.3", + "skills": "1.5.17", "tsup": "^8.5.1", "typedoc": "^0.28.19", "typescript": "^5.9.3", diff --git a/packages/bets-core/README.md b/packages/bets-core/README.md index 8c8e585..5995d1f 100644 --- a/packages/bets-core/README.md +++ b/packages/bets-core/README.md @@ -15,6 +15,8 @@ The package has zero runtime dependencies. Consumers can install Settlement OS, but app-side odds and rollup helpers do not pull the engine into the bundle. +## Install + ```bash npm install @buzzr/bets-core ``` @@ -162,3 +164,19 @@ calculateDrawdown(bets); calculateStreaks(bets); // { longestWinStreak: 2, longestLossStreak: 3, currentStreak: { status: 'won', count: 1 } } ``` + +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/bets-core`. +Deep `src/*` and `dist/*` imports are unsupported. See the +[all-package API index](../../docs/api-reference.md) and the +[generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_bets-core.html). + +Report reproducible defects in [GitHub Issues](https://github.com/Buzzr-app/dfs-engine/issues). +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + +## License + +[MIT](../../LICENSE) diff --git a/packages/dfs-cli/CHANGELOG.md b/packages/dfs-cli/CHANGELOG.md index 7ef6413..335d99f 100644 --- a/packages/dfs-cli/CHANGELOG.md +++ b/packages/dfs-cli/CHANGELOG.md @@ -1,5 +1,21 @@ # @buzzr/dfs-cli +## 5.0.1 + +### Patch Changes + +- 5be07fa: Start the installed `dfs-grade` executable correctly through npm bin symlinks and publish corrected policy-verification and regression-fixture guidance. +- Updated dependencies [5be07fa] + - @buzzr/dfs-engine@5.1.0 + +## 5.0.0 + +### Major Changes + +- Synchronized the CLI with the public v5 package train and moved its engine + dependency to `@buzzr/dfs-engine@^5.0.0` while preserving the `dfs-grade` + command and programmatic API. + ## 1.0.0 ### Major Changes diff --git a/packages/dfs-cli/README.md b/packages/dfs-cli/README.md index ff064b3..883f921 100644 --- a/packages/dfs-cli/README.md +++ b/packages/dfs-cli/README.md @@ -6,7 +6,9 @@ [![types](https://img.shields.io/npm/types/@buzzr/dfs-cli)](https://www.npmjs.com/package/@buzzr/dfs-cli) [![license](https://img.shields.io/npm/l/@buzzr/dfs-cli)](https://github.com/Buzzr-app/dfs-engine/blob/main/LICENSE) -**Grade a DFS pick'em entry from two JSON files — no code required.** A command-line wrapper around [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine), the settlement engine that grades PrizePicks/Underdog-style entries with book-accurate payout math and a full audit trail. +**Grade a DFS pick'em entry from two JSON files — no code required.** This is a command-line wrapper around [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine), with bounded inputs, effective-dated compatibility tables, policy metadata, and a full audit trail. + +The operator-named built-ins are independent estimates: PrizePicks is experimental and partially verified; Underdog is experimental and unverified. Displayed entry terms are authoritative. Treat output as an audit aid, inspect `policyStatus`, `policyVerification`, `payoutTable`, `confidence`, `sourceRefs`, and `explanationCodes`, and confirm DNP, reboot, tie, rescue, void, and correction rulings with the operator. ## 30-second quick start @@ -107,7 +109,6 @@ const fromFiles = await runGradeFromFiles({ | `dfs-grade` (bin) | Grade an entry JSON against a gamelogs JSON, print settlement | | `runGrade()` | Grade an in-memory `DfsEntryInput` against a `legId → gamelog[]` map | | `runGradeFromFiles()` | Same, reading both inputs from file paths | -| `createDfsEngine` et al | Re-exported engine primitives for convenience | ## When to use this vs siblings @@ -117,14 +118,25 @@ const fromFiles = await runGradeFromFiles({ | Grade entries inside a TypeScript/JavaScript app | [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine) | | Render results in a UI | [`@buzzr/dfs-react`](https://www.npmjs.com/package/@buzzr/dfs-react) | | Build fixtures for your own tests | [`@buzzr/dfs-testkit`](https://www.npmjs.com/package/@buzzr/dfs-testkit) | -| Verify your integration grades identically to Buzzr | [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) | +| Replay versioned engine regression cases | [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) | ## Links -- [Engine API docs](https://buzzr-app.github.io/dfs-engine/) +- [All-package API index](../../docs/api-reference.md) +- [Generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-cli.html) - [Monorepo & full package family](https://github.com/Buzzr-app/dfs-engine) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) +## Compatibility and support + +Node.js >= 22 is supported. Use the `dfs-grade` executable for the command line. +Import the supported API from `@buzzr/dfs-cli`. +Deep `src/*` and `dist/*` imports are unsupported. + +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/dfs-cli/package.json b/packages/dfs-cli/package.json index 4ed5c52..6e767ef 100644 --- a/packages/dfs-cli/package.json +++ b/packages/dfs-cli/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-cli", - "version": "5.0.0", + "version": "5.0.1", "description": "Command-line wrapper around @buzzr/dfs-engine — grade a DFS entry from JSON fixtures without writing code.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -55,7 +55,7 @@ "buzzr" ], "dependencies": { - "@buzzr/dfs-engine": "^5.0.0" + "@buzzr/dfs-engine": "^5.1.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-cli/src/cli.ts b/packages/dfs-cli/src/cli.ts index a234122..d5a31a7 100644 --- a/packages/dfs-cli/src/cli.ts +++ b/packages/dfs-cli/src/cli.ts @@ -1,4 +1,6 @@ import { runGradeFromFiles } from './index'; +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; type ParsedArgs = { entryPath: string | null; @@ -50,13 +52,16 @@ export async function main(argv: readonly string[]): Promise { } } -const isDirectInvocation = - typeof process !== 'undefined' && - Array.isArray(process.argv) && - process.argv[1] !== undefined && - import.meta.url === `file://${process.argv[1]}`; +export function isDirectInvocation(moduleUrl: string, argvPath: string | undefined): boolean { + if (!argvPath) return false; + try { + return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath); + } catch { + return false; + } +} -if (isDirectInvocation) { +if (isDirectInvocation(import.meta.url, process.argv[1])) { const code = await main(process.argv.slice(2)); process.exit(code); } diff --git a/packages/dfs-cli/tests/cli-main.test.ts b/packages/dfs-cli/tests/cli-main.test.ts index a05cb2f..53939b4 100644 --- a/packages/dfs-cli/tests/cli-main.test.ts +++ b/packages/dfs-cli/tests/cli-main.test.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { main } from '../src/cli'; +import { isDirectInvocation, main } from '../src/cli'; const entry = { entryId: 'cli-main-entry', @@ -138,4 +139,16 @@ describe('@buzzr/dfs-cli main()', () => { const code = await main(['--unknown', '-x']); expect(code).toBe(1); }); + + test('recognizes npm bin symlinks and URL-encoded paths as direct invocation', () => { + const dir = mkdtempSync(join(tmpdir(), 'dfs cli-direct-')); + const realCli = join(dir, 'real cli.js'); + const linkedCli = join(dir, 'dfs-grade'); + writeFileSync(realCli, '#!/usr/bin/env node\n'); + symlinkSync(realCli, linkedCli); + + expect(isDirectInvocation(pathToFileURL(realCli).href, linkedCli)).toBe(true); + expect(isDirectInvocation(pathToFileURL(realCli).href, join(dir, 'other-cli'))).toBe(false); + expect(isDirectInvocation(pathToFileURL(realCli).href, undefined)).toBe(false); + }); }); diff --git a/packages/dfs-engine-test-vectors/CHANGELOG.md b/packages/dfs-engine-test-vectors/CHANGELOG.md index 5c1ada8..e3daa78 100644 --- a/packages/dfs-engine-test-vectors/CHANGELOG.md +++ b/packages/dfs-engine-test-vectors/CHANGELOG.md @@ -1,5 +1,39 @@ # @buzzr/dfs-engine-test-vectors +## 5.1.0 + +### Minor Changes + +- 5be07fa: Expand the public regression set from three happy paths to twelve version-matched fixtures covering policy evidence, payout tables, DNPs, pushes, missing and unsupported stats, ambiguous provider rows, warnings, provenance, and audit codes. + +### Patch Changes + +- Updated dependencies [5be07fa] + - @buzzr/dfs-engine@5.1.0 + +### Details + +- Expanded the public regression set from three happy paths to twelve fixtures, + including all-push, explicit-DNP repricing, missing-vs-unsupported stats, + duplicate-player warnings, wrong-date and ambiguous provider rows, and the + PrizePicks standard three-pick table and sourced one-win/one-tie two-pick + Power payout effective 2026-07-02. +- Pinned full top-level settlement behavior: payout, effective policy/table, + verification, confidence, validation, sources, provenance, explanations, audit + codes, pending reasons, and per-leg provider detail. + +### Documentation + +- Define the package as engine regression fixtures for the matching engine + version, not proof of current operator rules or official operator equivalence. + +## 5.0.0 + +### Major Changes + +- Synchronized with `@buzzr/dfs-engine@5.0.0` and published three deterministic + PrizePicks/Underdog regression fixtures for the v5 settlement contract. + ## 1.0.0 ### Major Changes diff --git a/packages/dfs-engine-test-vectors/README.md b/packages/dfs-engine-test-vectors/README.md index 00bd620..f078e8e 100644 --- a/packages/dfs-engine-test-vectors/README.md +++ b/packages/dfs-engine-test-vectors/README.md @@ -6,16 +6,19 @@ [![types](https://img.shields.io/npm/types/@buzzr/dfs-engine-test-vectors)](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) [![license](https://img.shields.io/npm/l/@buzzr/dfs-engine-test-vectors)](https://github.com/Buzzr-app/dfs-engine/blob/main/LICENSE) -**Golden fixtures that prove your [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine) integration grades identically to Buzzr's production pipeline.** Each vector is an `{ entry, gameLogsByLegId, expected }` triple — a real slip shape, the boxscore rows that settle it, and the exact settlement outcome — verified against the engine in this package's own test suite. +**Versioned engine regression fixtures for [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine).** Each vector is an `{ entry, gameLogsByLegId, expected }` triple: a synthetic entry, deterministic provider rows, and the detailed outcome produced by the matching engine version. -If you wire your own stat providers, persistence, or settlement orchestration around the engine, replaying these vectors in your CI turns "we think our wiring is right" into a passing conformance test. +Replay these fixtures when wiring stat providers, persistence, or settlement orchestration to detect drift from that engine contract. They are not official operator conformance, do not certify current PrizePicks or Underdog rules, and do not replace the displayed entry terms or an operator ruling. ## Install ```bash -npm install --save-dev @buzzr/dfs-engine-test-vectors @buzzr/dfs-engine +npm install --save-dev @buzzr/dfs-engine-test-vectors@5.1.0 @buzzr/dfs-engine@5.1.0 ``` +The fixtures and engine are pinned together because each vector describes one +specific engine contract. Upgrade and review both versions together. + ## 30-second quick start ```ts @@ -23,7 +26,7 @@ import { describe, expect, test } from 'vitest'; import { createDfsEngine, defineStatProvider } from '@buzzr/dfs-engine'; import { TEST_VECTORS } from '@buzzr/dfs-engine-test-vectors'; -describe('my-integration conformance', () => { +describe('my-integration engine regression vectors', () => { test.each(TEST_VECTORS.map((v) => [v.name, v] as const))('%s', async (_name, v) => { const provider = defineStatProvider({ id: 'mine', @@ -33,7 +36,17 @@ describe('my-integration conformance', () => { const result = await engine.settleEntry(v.entry, { statProviderId: 'mine' }); - expect(result.status).toBe(v.expected.status); + expect(result).toMatchObject({ + status: v.expected.status, + multiplier: v.expected.multiplier, + payout: v.expected.payout, + pendingReasons: v.expected.pendingReasons, + policyVersion: v.expected.policyVersion, + policyStatus: v.expected.policyStatus, + payoutTable: v.expected.payoutTable, + confidence: v.expected.confidence, + explanationCodes: v.expected.explanationCodes, + }); for (const expectedLeg of v.expected.legs) { const leg = result.legs.find((l) => l.legId === expectedLeg.legId); expect(leg?.status).toBe(expectedLeg.status); @@ -45,33 +58,52 @@ describe('my-integration conformance', () => { ## What's inside -| Vector | Covers | -| --------------------------------- | ------------------------------------------------------------------- | -| `prizepicks_power_2leg_all_win` | PrizePicks Power, both NBA overs clear the line — settles `won` | -| `prizepicks_power_2leg_one_loss` | All-or-nothing: one missed leg loses the whole Power entry | -| `underdog_standard_2leg_under_hits` | Underdog Standard, both unders hit — settles `won` | +| Vector | Engine behavior pinned | +| --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `prizepicks_power_2leg_all_win` | Two NBA overs win under the PrizePicks compatibility profile | +| `prizepicks_power_2leg_one_loss` | One missed leg loses an all-or-nothing compatibility entry | +| `underdog_standard_2leg_under_hits` | Two unders win under the unverified Underdog compatibility profile | +| `prizepicks_power_all_push_refund` | All exact-line pushes remove every leg and return the stake | +| `prizepicks_power_dnp_reprices_survivors` | An explicit DNP removes a leg and reprices the surviving entry | +| `prizepicks_power_missing_supported_stat_pending` | A supported prop with a missing value remains pending as `missing_stat` | +| `prizepicks_power_unsupported_prop_pending` | An unsupported prop remains pending distinctly from missing data | +| `prizepicks_flex_duplicate_player_warning` | Duplicate-player validation warning survives a completed settlement | +| `prizepicks_power_wrong_date_provider_row_pending` | A provider row outside the game-date window is not silently selected | +| `prizepicks_power_ambiguous_same_day_rows_pending` | Two plausible same-day rows remain pending instead of being chosen arbitrarily | +| `prizepicks_power_current_3leg_payout` | The reviewed standard table effective 2026-07-02 resolves a three-pick Power entry at 6× | | Export | Purpose | | -------------------- | ---------------------------------------------------------------------- | -| `TEST_VECTORS` | Readonly array of verified `TestVector` triples | -| `TestVector` | Type: `{ name, description, entry, gameLogsByLegId, expected }` | -| `ExpectedLegOutcome` | Type: expected per-leg `{ legId, status, actual }` | +| `TEST_VECTORS` | Readonly array of engine-verified `TestVector` triples | +| `TestVector` | Type: `{ name, description, entry, gameLogsByLegId, expected }` | +| `ExpectedSettlement` | Expected payout, policy, verification, provenance, validation, and audit contract | +| `ExpectedLegOutcome` | Expected per-leg status, actual, pending reason, and provider source | ## When to use this vs siblings | You want to… | Reach for | | -------------------------------------------------- | ------------------------------------------------------------------------ | -| Prove your integration matches Buzzr's grading | **this package** | +| Detect drift from a matching engine version | **this package** | | Build custom fixtures for your own test scenarios | [`@buzzr/dfs-testkit`](https://www.npmjs.com/package/@buzzr/dfs-testkit) | | Grade entries in production code | [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine) | | Spot-check a single entry from the command line | [`@buzzr/dfs-cli`](https://www.npmjs.com/package/@buzzr/dfs-cli) | ## Links -- [Engine API docs](https://buzzr-app.github.io/dfs-engine/) +- [All-package API index](../../docs/api-reference.md) +- [Generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-engine-test-vectors.html) - [Monorepo & full package family](https://github.com/Buzzr-app/dfs-engine) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/dfs-engine-test-vectors`. +Deep `src/*` and `dist/*` imports are unsupported. + +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/dfs-engine-test-vectors/package.json b/packages/dfs-engine-test-vectors/package.json index 6799e12..5442a26 100644 --- a/packages/dfs-engine-test-vectors/package.json +++ b/packages/dfs-engine-test-vectors/package.json @@ -1,7 +1,7 @@ { "name": "@buzzr/dfs-engine-test-vectors", - "version": "5.0.0", - "description": "Canonical reference fixtures for @buzzr/dfs-engine — golden entry/gamelog/settlement triples so external integrators can prove their wiring matches Buzzr's.", + "version": "5.1.0", + "description": "Versioned engine regression fixtures for @buzzr/dfs-engine; not official operator conformance.", "license": "MIT", "author": "Sarvesh Chidambaram", "repository": { @@ -42,7 +42,7 @@ "daily-fantasy-sports", "test-vectors", "fixtures", - "golden-tests", + "regression-fixtures", "conformance", "settlement", "grading", @@ -52,7 +52,7 @@ "buzzr" ], "dependencies": { - "@buzzr/dfs-engine": "^5.0.0" + "@buzzr/dfs-engine": "5.1.0" }, "engines": { "node": ">=22" diff --git a/packages/dfs-engine-test-vectors/src/index.ts b/packages/dfs-engine-test-vectors/src/index.ts index 1409455..489a8ce 100644 --- a/packages/dfs-engine-test-vectors/src/index.ts +++ b/packages/dfs-engine-test-vectors/src/index.ts @@ -1,9 +1,40 @@ -import type { DfsEntryInput, DfsLegOutcome, PlayerGameLogEntryShape } from '@buzzr/dfs-engine'; +import type { + DfsEntryInput, + DfsLegOutcome, + DfsPolicyStatus, + DfsPolicyVerificationStatus, + DfsSettlementConfidence, + PlayerGameLogEntryShape, +} from '@buzzr/dfs-engine'; export type ExpectedLegOutcome = { legId: string; status: DfsLegOutcome; actual: number | null; + /** Added in v5.1; omitted by legacy consumer-authored fixtures. */ + pendingReason?: string | null; + /** Added in v5.1; omitted by legacy consumer-authored fixtures. */ + providerSource?: string; +}; + +export type ExpectedSettlement = { + status: 'won' | 'lost' | 'pushed' | 'pending' | 'void'; + /** Additive v5.1 settlement assertions; optional for legacy consumer-authored fixtures. */ + multiplier?: number; + effectiveMultiplier?: number; + payout?: { total: number; withdrawable: number; bonus: number }; + pendingReasons?: string[]; + policyVersion?: string | null; + policyStatus?: DfsPolicyStatus | null; + policyVerificationStatus?: DfsPolicyVerificationStatus | null; + payoutTable?: { version: string | null; effectiveFrom: string } | null; + confidence?: DfsSettlementConfidence; + explanationCodes?: string[]; + validation?: { errorCodes: string[]; warningCodes: string[] }; + sourceLabels?: string[]; + providerSources?: string[]; + auditCodes?: string[]; + legs: ExpectedLegOutcome[]; }; export type TestVector = { @@ -11,15 +42,13 @@ export type TestVector = { description: string; entry: DfsEntryInput; gameLogsByLegId: Record; - expected: { - status: 'won' | 'lost' | 'pushed' | 'pending' | 'void'; - legs: ExpectedLegOutcome[]; - }; + expected: ExpectedSettlement; }; -const NBA_GAME_DATE = '2026-05-07T00:00:00.000Z'; +const NBA_GAME_DATE = '2026-07-16T20:00:00.000Z'; +const PLACED_AT = '2026-07-16T12:00:00.000Z'; -function nbaRow(overrides: Partial): PlayerGameLogEntryShape { +function nbaRow(overrides: Partial = {}): PlayerGameLogEntryShape { return { date: NBA_GAME_DATE, minutes: '34:00', @@ -34,149 +63,650 @@ function nbaRow(overrides: Partial): PlayerGameLogEntry }; } +function leg( + index: number, + overrides: Partial = {}, +): DfsEntryInput['legs'][number] { + return { + legId: `leg-${index}`, + playerName: `Vector Player ${index}`, + playerId: `athlete-${index}`, + league: 'NBA', + propType: 'Points', + line: 20.5, + direction: 'over', + actual: null, + status: 'pending', + gameDate: NBA_GAME_DATE, + ...overrides, + }; +} + +type CompleteExpectedLegOutcome = ExpectedLegOutcome & { + pendingReason: string | null; + providerSource: string; +}; + +type ExpectedBuilderInput = { + status: ExpectedSettlement['status']; + multiplier: number; + payout?: ExpectedSettlement['payout']; + pendingReasons?: string[]; + confidence?: DfsSettlementConfidence; + explanationCodes: string[]; + validation?: ExpectedSettlement['validation']; + legs: CompleteExpectedLegOutcome[]; +}; + +type CompleteExpectedSettlement = Required> & { + status: ExpectedSettlement['status']; + legs: CompleteExpectedLegOutcome[]; +}; + +function expectedSettlement( + profile: 'prizepicks' | 'underdog', + input: ExpectedBuilderInput, +): CompleteExpectedSettlement { + const prizePicks = profile === 'prizepicks'; + return { + status: input.status, + multiplier: input.multiplier, + effectiveMultiplier: input.multiplier, + payout: input.payout ?? { total: 0, withdrawable: 0, bonus: 0 }, + pendingReasons: input.pendingReasons ?? [], + policyVersion: '2026-05', + policyStatus: 'experimental', + policyVerificationStatus: prizePicks ? 'partial' : 'unverified', + payoutTable: prizePicks + ? { version: '2026-07-02-player-picks', effectiveFrom: '2026-07-02' } + : { version: '2026-05', effectiveFrom: '2026-05-01' }, + confidence: input.confidence ?? (prizePicks ? 'medium' : 'low'), + explanationCodes: input.explanationCodes, + validation: input.validation ?? { errorCodes: [], warningCodes: [] }, + sourceLabels: prizePicks + ? [ + 'PrizePicks Payouts', + 'PrizePicks Potential Outcomes', + 'PrizePicks DNPs, Reboots, and Ties', + ] + : ['Underdog Sports Legal Center'], + providerSources: input.legs.map((item) => item.providerSource), + auditCodes: ['settlement.started', 'settlement.policy_selected', `settlement.${input.status}`], + legs: input.legs, + }; +} + export const TEST_VECTORS: readonly TestVector[] = [ { name: 'prizepicks_power_2leg_all_win', - description: 'PrizePicks Power, two NBA Points overs both clear the line — should settle won.', + description: + 'PrizePicks Power compatibility profile, two NBA Points overs both clear the line.', entry: { entryId: 'tv-power-2leg-won', bookId: 'prizepicks', playTypeId: 'power', stake: 10, displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1, { line: 20.5 }), leg(2, { line: 15.5 })], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '28' })], + 'leg-2': [nbaRow({ points: '22' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'won', + multiplier: 3, + payout: { total: 30, withdrawable: 30, bonus: 0 }, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'settlement.fixed_table_payout', + 'payout_table_lookup', + ], legs: [ { legId: 'leg-1', - playerName: 'Vector Player A', - playerId: 'athlete-a', - league: 'NBA', - propType: 'Points', - line: 20.5, - direction: 'over', - actual: null, - status: 'pending', - gameDate: NBA_GAME_DATE, + status: 'won', + actual: 28, + pendingReason: null, + providerSource: 'stat-provider', }, { legId: 'leg-2', - playerName: 'Vector Player B', - playerId: 'athlete-b', - league: 'NBA', - propType: 'Points', - line: 15.5, - direction: 'over', - actual: null, - status: 'pending', - gameDate: NBA_GAME_DATE, + status: 'won', + actual: 22, + pendingReason: null, + providerSource: 'stat-provider', }, ], + }), + }, + { + name: 'prizepicks_power_2leg_one_loss', + description: 'PrizePicks Power compatibility profile loses when one active leg misses.', + entry: { + entryId: 'tv-power-2leg-lost', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1, { line: 20.5 }), leg(2, { propType: 'Rebounds', line: 7.5 })], }, gameLogsByLegId: { 'leg-1': [nbaRow({ points: '28' })], - 'leg-2': [nbaRow({ points: '22' })], + 'leg-2': [nbaRow({ rebounds: '4' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'lost', + multiplier: 0, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'settlement.all_or_nothing_loss', + 'payout_table_lookup', + ], + legs: [ + { + legId: 'leg-1', + status: 'won', + actual: 28, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-2', + status: 'lost', + actual: 4, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), + }, + { + name: 'prizepicks_power_2leg_one_win_one_tie', + description: + 'Current PrizePicks two-pick Power policy pays 1.5x when one projection wins and one ties.', + entry: { + entryId: 'tv-power-2leg-win-tie', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1, { line: 20.5 }), leg(2, { propType: 'Rebounds', line: 7 })], }, - expected: { + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '28' })], + 'leg-2': [nbaRow({ rebounds: '7' })], + }, + expected: expectedSettlement('prizepicks', { status: 'won', + multiplier: 1.5, + payout: { total: 15, withdrawable: 15, bonus: 0 }, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.push_removed', + 'push_leg_removed', + 'settlement.fixed_table_payout', + 'payout_table_lookup', + 'settlement.repriced_after_removed_legs', + ], + legs: [ + { + legId: 'leg-1', + status: 'won', + actual: 28, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-2', + status: 'push', + actual: 7, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), + }, + { + name: 'underdog_standard_2leg_under_hits', + description: 'Unverified Underdog Standard compatibility profile with two unders clearing.', + entry: { + entryId: 'tv-underdog-2leg-won', + bookId: 'underdog', + playTypeId: 'underdog_standard', + stake: 5, + displayedMultiplier: 3, + placedAt: PLACED_AT, legs: [ - { legId: 'leg-1', status: 'won', actual: 28 }, - { legId: 'leg-2', status: 'won', actual: 22 }, + leg(1, { propType: 'Turnovers', line: 3.5, direction: 'under' }), + leg(2, { propType: 'Assists', line: 8.5, direction: 'under' }), ], }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ turnovers: '2' })], + 'leg-2': [nbaRow({ assists: '6' })], + }, + expected: expectedSettlement('underdog', { + status: 'won', + multiplier: 3, + payout: { total: 15, withdrawable: 15, bonus: 0 }, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.unverified', + 'settlement.fixed_table_payout', + 'payout_table_lookup', + ], + legs: [ + { + legId: 'leg-1', + status: 'won', + actual: 2, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-2', + status: 'won', + actual: 6, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), }, { - name: 'prizepicks_power_2leg_one_loss', - description: 'PrizePicks Power loses entirely when a single leg misses (all-or-nothing).', + name: 'prizepicks_power_all_push_refund', + description: 'Two exact-line ties remove every leg and return the stake as pushed.', entry: { - entryId: 'tv-power-2leg-lost', + entryId: 'tv-power-all-push', bookId: 'prizepicks', playTypeId: 'power', stake: 10, displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1, { line: 20 }), leg(2, { propType: 'Rebounds', line: 5 })], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '20' })], + 'leg-2': [nbaRow({ rebounds: '5' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'pushed', + multiplier: 1, + payout: { total: 10, withdrawable: 10, bonus: 0 }, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.push_removed', + 'push_leg_removed', + 'settlement.all_legs_removed_refund', + 'refund_no_survivors', + ], legs: [ { legId: 'leg-1', - playerName: 'Vector Player A', - playerId: 'athlete-a', - league: 'NBA', - propType: 'Points', - line: 20.5, - direction: 'over', - actual: null, - status: 'pending', - gameDate: NBA_GAME_DATE, + status: 'push', + actual: 20, + pendingReason: null, + providerSource: 'stat-provider', }, { legId: 'leg-2', - playerName: 'Vector Player B', - playerId: 'athlete-b', - league: 'NBA', - propType: 'Rebounds', - line: 7.5, - direction: 'over', + status: 'push', + actual: 5, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), + }, + { + name: 'prizepicks_power_dnp_reprices_survivors', + description: 'A ruled DNP is removed and the current three-pick entry reprices to two picks.', + entry: { + entryId: 'tv-power-dnp-reprice', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 6, + placedAt: PLACED_AT, + legs: [leg(1, { status: 'dnp' }), leg(2), leg(3)], + }, + gameLogsByLegId: { + 'leg-2': [nbaRow({ points: '25' })], + 'leg-3': [nbaRow({ points: '24' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'won', + multiplier: 3, + payout: { total: 30, withdrawable: 30, bonus: 0 }, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.dnp', + 'dnp_leg_removed', + 'settlement.fixed_table_payout', + 'payout_table_lookup', + 'settlement.repriced_after_removed_legs', + ], + legs: [ + { + legId: 'leg-1', + status: 'dnp', actual: null, + pendingReason: null, + providerSource: 'status', + }, + { + legId: 'leg-2', + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-3', + status: 'won', + actual: 24, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), + }, + { + name: 'prizepicks_power_missing_supported_stat_pending', + description: 'A valid NBA row with a missing supported Points value remains pending.', + entry: { + entryId: 'tv-power-missing-stat', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1), leg(2)], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '-' })], + 'leg-2': [nbaRow({ points: '25' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'pending', + multiplier: 0, + pendingReasons: ['leg-1:missing_stat'], + confidence: 'low', + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.pending.missing_stat', + ], + legs: [ + { + legId: 'leg-1', status: 'pending', - gameDate: NBA_GAME_DATE, + actual: null, + pendingReason: 'missing_stat', + providerSource: 'vector-provider', + }, + { + legId: 'leg-2', + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', }, ], + }), + }, + { + name: 'prizepicks_power_unsupported_prop_pending', + description: 'A syntactically valid but unsupported prop is distinct from missing stat data.', + entry: { + entryId: 'tv-power-unsupported-prop', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1, { propType: 'First Basket' }), leg(2)], }, gameLogsByLegId: { - 'leg-1': [nbaRow({ points: '28' })], - 'leg-2': [nbaRow({ rebounds: '4' })], + 'leg-1': [nbaRow()], + 'leg-2': [nbaRow({ points: '25' })], }, - expected: { - status: 'lost', + expected: expectedSettlement('prizepicks', { + status: 'pending', + multiplier: 0, + pendingReasons: ['leg-1:unsupported_prop'], + confidence: 'low', + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.pending.unsupported_prop', + ], legs: [ - { legId: 'leg-1', status: 'won', actual: 28 }, - { legId: 'leg-2', status: 'lost', actual: 4 }, + { + legId: 'leg-1', + status: 'pending', + actual: null, + pendingReason: 'unsupported_prop', + providerSource: 'vector-provider', + }, + { + legId: 'leg-2', + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', + }, ], + }), + }, + { + name: 'prizepicks_flex_duplicate_player_warning', + description: 'Duplicate player selections settle but preserve a policy validation warning.', + entry: { + entryId: 'tv-flex-duplicate-warning', + bookId: 'prizepicks', + playTypeId: 'flex', + stake: 10, + displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [ + leg(1, { playerId: 'duplicate-athlete' }), + leg(2, { playerId: 'duplicate-athlete', propType: 'Rebounds', line: 5.5 }), + leg(3, { propType: 'Assists', line: 6.5 }), + ], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ points: '25' })], + 'leg-2': [nbaRow({ rebounds: '8' })], + 'leg-3': [nbaRow({ assists: '9' })], }, + expected: expectedSettlement('prizepicks', { + status: 'won', + multiplier: 3, + payout: { total: 30, withdrawable: 30, bonus: 0 }, + explanationCodes: [ + 'validation.duplicate_player', + 'policy.status.experimental', + 'policy.verification.partial', + 'settlement.fixed_table_payout', + 'payout_table_lookup', + ], + validation: { errorCodes: [], warningCodes: ['validation.duplicate_player'] }, + legs: [ + { + legId: 'leg-1', + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-2', + status: 'won', + actual: 8, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-3', + status: 'won', + actual: 9, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), }, { - name: 'underdog_standard_2leg_under_hits', - description: 'Underdog Standard two-leg slate with both unders clearing — wins.', + name: 'prizepicks_power_wrong_date_provider_row_pending', + description: 'A valid player row outside the game-date window is not silently selected.', entry: { - entryId: 'tv-underdog-2leg-won', - bookId: 'underdog', - playTypeId: 'underdog_standard', - stake: 5, + entryId: 'tv-power-wrong-date', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1), leg(2)], + }, + gameLogsByLegId: { + 'leg-1': [nbaRow({ date: '2026-07-12T20:00:00.000Z', points: '40' })], + 'leg-2': [nbaRow({ points: '25' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'pending', + multiplier: 0, + pendingReasons: ['leg-1:missing_provider_data'], + confidence: 'low', + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.pending.missing_provider_data', + ], legs: [ { legId: 'leg-1', - playerName: 'Vector Player C', - playerId: 'athlete-c', - league: 'NBA', - propType: 'Turnovers', - line: 3.5, - direction: 'under', - actual: null, status: 'pending', - gameDate: NBA_GAME_DATE, + actual: null, + pendingReason: 'missing_provider_data', + providerSource: 'vector-provider', }, { legId: 'leg-2', - playerName: 'Vector Player D', - playerId: 'athlete-d', - league: 'NBA', - propType: 'Assists', - line: 8.5, - direction: 'under', - actual: null, + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', + }, + ], + }), + }, + { + name: 'prizepicks_power_ambiguous_same_day_rows_pending', + description: 'Two plausible same-day rows surface ambiguity instead of choosing one silently.', + entry: { + entryId: 'tv-power-ambiguous-rows', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + placedAt: PLACED_AT, + legs: [leg(1), leg(2)], + }, + gameLogsByLegId: { + 'leg-1': [ + nbaRow({ date: '2026-07-16T13:00:00.000Z', points: '18' }), + nbaRow({ date: '2026-07-16T17:00:00.000Z', points: '28' }), + ], + 'leg-2': [nbaRow({ points: '25' })], + }, + expected: expectedSettlement('prizepicks', { + status: 'pending', + multiplier: 0, + pendingReasons: ['leg-1:missing_provider_data'], + confidence: 'low', + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'leg.pending.missing_provider_data', + ], + legs: [ + { + legId: 'leg-1', status: 'pending', - gameDate: NBA_GAME_DATE, + actual: null, + pendingReason: 'missing_provider_data', + providerSource: 'vector-provider', + }, + { + legId: 'leg-2', + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', }, ], + }), + }, + { + name: 'prizepicks_power_current_3leg_payout', + description: 'The July 2, 2026 standard three-pick Power table resolves at 6x.', + entry: { + entryId: 'tv-power-current-3leg', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 6, + placedAt: PLACED_AT, + legs: [leg(1), leg(2), leg(3)], }, gameLogsByLegId: { - 'leg-1': [nbaRow({ turnovers: '2' })], - 'leg-2': [nbaRow({ assists: '6' })], + 'leg-1': [nbaRow({ points: '25' })], + 'leg-2': [nbaRow({ points: '24' })], + 'leg-3': [nbaRow({ points: '23' })], }, - expected: { + expected: expectedSettlement('prizepicks', { status: 'won', + multiplier: 6, + payout: { total: 60, withdrawable: 60, bonus: 0 }, + explanationCodes: [ + 'policy.status.experimental', + 'policy.verification.partial', + 'settlement.fixed_table_payout', + 'payout_table_lookup', + ], legs: [ - { legId: 'leg-1', status: 'won', actual: 2 }, - { legId: 'leg-2', status: 'won', actual: 6 }, + { + legId: 'leg-1', + status: 'won', + actual: 25, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-2', + status: 'won', + actual: 24, + pendingReason: null, + providerSource: 'stat-provider', + }, + { + legId: 'leg-3', + status: 'won', + actual: 23, + pendingReason: null, + providerSource: 'stat-provider', + }, ], - }, + }), }, ]; diff --git a/packages/dfs-engine-test-vectors/tests/vectors.test.ts b/packages/dfs-engine-test-vectors/tests/vectors.test.ts index 3d5ba16..4641db6 100644 --- a/packages/dfs-engine-test-vectors/tests/vectors.test.ts +++ b/packages/dfs-engine-test-vectors/tests/vectors.test.ts @@ -23,9 +23,44 @@ describe('@buzzr/dfs-engine-test-vectors', () => { const engine = createDfsEngine({ statProviders: [provider] }); const result = await engine.settleEntry(vector.entry, { statProviderId: provider.id, + settledAt: '2026-07-16T12:00:00.000Z', }); - expect(result.status, `${vector.name} bet status`).toBe(vector.expected.status); + expect(result, `${vector.name} top-level contract`).toMatchObject({ + status: vector.expected.status, + multiplier: vector.expected.multiplier, + effectiveMultiplier: vector.expected.effectiveMultiplier, + payout: vector.expected.payout, + pendingReasons: vector.expected.pendingReasons, + policyVersion: vector.expected.policyVersion, + policyStatus: vector.expected.policyStatus, + payoutTable: vector.expected.payoutTable, + confidence: vector.expected.confidence, + explanationCodes: vector.expected.explanationCodes, + }); + expect(result.policyVerification?.status ?? null, `${vector.name} policy verification`).toBe( + vector.expected.policyVerificationStatus, + ); + expect( + result.validation.errors.map((issue) => issue.code), + `${vector.name} validation errors`, + ).toEqual(vector.expected.validation.errorCodes); + expect( + result.validation.warnings.map((issue) => issue.code), + `${vector.name} validation warnings`, + ).toEqual(vector.expected.validation.warningCodes); + expect( + result.sourceRefs.map((source) => source.label), + `${vector.name} policy sources`, + ).toEqual(vector.expected.sourceLabels); + expect( + result.provenance.providers.map((provider) => provider.source), + `${vector.name} provider provenance`, + ).toEqual(vector.expected.providerSources); + expect( + result.auditTrail.map((event) => event.code), + `${vector.name} audit trail`, + ).toEqual(vector.expected.auditCodes); expect(result.legs.length, `${vector.name} leg count`).toBe(vector.expected.legs.length); for (const expectedLeg of vector.expected.legs) { @@ -37,6 +72,14 @@ describe('@buzzr/dfs-engine-test-vectors', () => { expect(actualLeg?.actual, `${vector.name} leg ${expectedLeg.legId} actual`).toBe( expectedLeg.actual, ); + expect( + actualLeg?.pendingReason ?? null, + `${vector.name} leg ${expectedLeg.legId} pending reason`, + ).toBe(expectedLeg.pendingReason); + expect( + actualLeg?.provider.source, + `${vector.name} leg ${expectedLeg.legId} provider source`, + ).toBe(expectedLeg.providerSource); } } }); @@ -46,4 +89,25 @@ describe('@buzzr/dfs-engine-test-vectors', () => { const names = new Set(TEST_VECTORS.map((v) => v.name)); expect(names.size).toBe(TEST_VECTORS.length); }); + + test('publishes adversarial settlement vectors, not only happy-path examples', () => { + const names = new Set(TEST_VECTORS.map((vector) => vector.name)); + + expect(names).toEqual( + new Set([ + 'prizepicks_power_2leg_all_win', + 'prizepicks_power_2leg_one_loss', + 'prizepicks_power_2leg_one_win_one_tie', + 'underdog_standard_2leg_under_hits', + 'prizepicks_power_all_push_refund', + 'prizepicks_power_dnp_reprices_survivors', + 'prizepicks_power_missing_supported_stat_pending', + 'prizepicks_power_unsupported_prop_pending', + 'prizepicks_flex_duplicate_player_warning', + 'prizepicks_power_wrong_date_provider_row_pending', + 'prizepicks_power_ambiguous_same_day_rows_pending', + 'prizepicks_power_current_3leg_payout', + ]), + ); + }); }); diff --git a/packages/dfs-engine/CHANGELOG.md b/packages/dfs-engine/CHANGELOG.md index 8d7ce20..f9cfa28 100644 --- a/packages/dfs-engine/CHANGELOG.md +++ b/packages/dfs-engine/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## 5.1.0 + +### Minor Changes + +- 5be07fa: Add effective-dated policy verification, source, payout-table, and immutable snapshot APIs while preserving v5 type compatibility. Correct explicit-status settlement, DNP/tie demotion and refunds, date-aware provider selection, all-removed outcomes, batch-cache annotations, and monetary rounding. + +### Corrected + +- Reclassified the built-in PrizePicks profile as experimental and partially + verified, and the Underdog profile as experimental and unverified. Operator-named + profiles are independent compatibility estimates; displayed entry terms and + explicit operator rulings remain authoritative. +- Added effective-dated PrizePicks standard Player Pick payout tables from the + first-party sources reviewed on 2026-07-16 while retaining the May 2026 + compatibility snapshots for historical entry selection. +- Added immutable policy verification/source snapshots to results and public + policy listing, confidence caps for incomplete verification, and explicit + explanation codes. +- Corrected DNP/tie demotion scaling, date-aware provider-row selection, + all-push/all-DNP status handling, immutable batch cache annotations, and + half-cent payout rounding. +- Corrected the current two-pick PrizePicks Power outcome with one win and one + tied projection to the sourced 1.5x standard payout; one loss plus one tie + remains a loss, while a DNP below the two-pick minimum remains a refund. +- Reject malformed verification notes and runtime date metadata without + coercion, while preserving the legacy definition API's shallow nested + mutability for minor-version compatibility. +- Keep zero-loss settlements pending when no payout-table row exists instead + of converting missing policy data into a financial loss, prefer + outcome-specific rows over generic rows regardless of declaration order, + and validate payout-table source dates as strings at runtime. + +### Documentation + +- Reframed test vectors as engine regression fixtures rather than proof of + operator behavior, and documented current source links and compatibility limits. + ## 5.0.0 — Batch Settlement + Declarative Policies Baselines the whole @buzzr family at v5. No breaking changes to the locked @@ -80,7 +117,8 @@ Turns the Settlement OS into a book-policy registry instead of a PrizePicks/Unde ### Changed - `DfsEntryInput` now prefers `bookId` and `playTypeId`. -- PrizePicks and Underdog are still stable built-ins, but their payout/tie/DNP behavior now flows through the policy registry. +- The bundled PrizePicks and Underdog compatibility behavior flows through the + policy registry. Version 5.1.0 corrects their public status and verification metadata. - `adaptBuzzrBetInput(...)` emits the v3 entry shape while preserving the current Buzzr `CreateDfsBetInput` migration path. - `@buzzr/dfs-testkit` fixture entries now use valid v3 book/play IDs. diff --git a/packages/dfs-engine/README.md b/packages/dfs-engine/README.md index 7780ca1..8c1f10a 100644 --- a/packages/dfs-engine/README.md +++ b/packages/dfs-engine/README.md @@ -3,10 +3,12 @@ [![npm version](https://img.shields.io/npm/v/@buzzr/dfs-engine.svg)](https://www.npmjs.com/package/@buzzr/dfs-engine) [![ci](https://github.com/Buzzr-app/dfs-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/Buzzr-app/dfs-engine/actions/workflows/ci.yml) -Pure-functional **DFS prop grading**, payout math, stat normalization, and policy-aware settlement for DFS pick'em apps. PrizePicks and Underdog ship as stable built-ins, and v4 gives you strict settlement inputs, structured validation, and custom book policies without forking. Drop-in TypeScript, zero runtime dependencies, ESM + CJS + `.d.ts` shipped. +Pure-functional **DFS prop grading**, payout math, stat normalization, and policy-aware settlement for DFS pick'em apps. PrizePicks is an experimental, partially verified compatibility profile; Underdog is experimental and unverified. These independent profiles are not official operator rules engines. The package also supports strict settlement inputs, structured validation, and custom book policies without forking. Drop-in TypeScript, zero runtime dependencies, ESM + CJS + `.d.ts` shipped. **Sports covered:** NBA, WNBA, NCAAM/W, NFL, MLB, NHL, EPL, MLS, La Liga, NWSL, UEFA Champions League. ~70 props. +## Install + ```bash npm install @buzzr/dfs-engine ``` @@ -17,10 +19,10 @@ If you're building a DFS-adjacent tool — a bet tracker, parlay analyzer, EV ca - **Did this leg hit?** Given a player's actual stat and a slip line, decide won / lost / push. - **What does the slip pay out?** Given the play type (Power / Flex / Standard), the pick count, the hits, and any boost, compute the multiplier and the withdrawable-vs-bonus split. -- **What happens when a player doesn't play?** Demote a six-pick to a five-pick (PrizePicks) or scratch and rescale (Underdog). +- **What happens when a player doesn't play?** Apply a selected compatibility or custom policy, preserve the decision in the audit trail, and surface uncertainty instead of inventing an operator ruling. - **What stat goes into a `Pts + Rebs + Asts` leg?** Or `Pass + Rush + Rec Yds`? Or `Hitter FS`? -There's no good open-source TypeScript package for any of this. Everyone reinvents it from scratch, usually wrong. This is the version extracted from [Buzzr](https://buzzr.app), where it's been settling real money lines in production. Pure functions, strict runtime validation, and a release suite covering 300+ settlement tests. +There's no good open-source TypeScript package for any of this. Everyone reinvents it from scratch, usually wrong. This package was extracted from [Buzzr](https://apps.apple.com/us/app/buzzr-sports/id6760628256) and is maintained as an independent, testable engine. Pure functions, strict runtime validation, and a release suite covering 300+ settlement tests keep code behavior auditable without claiming that an operator will issue the same ruling. ## Quickstart @@ -137,6 +139,24 @@ Optional SDK packages: - `@buzzr/dfs-provider-espn` wraps your ESPN-shaped loader as a stat provider. - `@buzzr/dfs-testkit` ships fixture builders and mock providers for settlement tests. +## Operator compatibility contract + +The operator-named built-ins are versioned compatibility profiles, not affiliated or endorsed implementations: + +- **PrizePicks:** `status: "experimental"` with `verification.status: "partial"`. Standard Player Pick references were reviewed on 2026-07-16 from [PrizePicks Payouts](https://www.prizepicks.com/help-center/payouts), [PrizePicks Potential Outcomes](https://www.prizepicks.com/help-center/potential-outcomes), and [DNPs, Reboots, and Ties](https://www.prizepicks.com/help-center/dnps-reboots-and-ties). For a 2-pick Power entry, one correct pick plus one tie pays 1.5x, one incorrect pick plus one tie loses, and a DNP below the two-pick minimum receives a refund. Other settlement behavior and variable lineup-specific payouts remain incomplete. +- **Underdog:** `status: "experimental"` with `verification.status: "unverified"`. The [Underdog Sports Legal Center](https://legal.underdogsports.com/) is recorded as the rules entrypoint; current payout and settlement values in the compatibility snapshot have not been verified. + +The displayed lineup terms are authoritative. Record `placedAt` so the engine can select an effective-dated payout table, inspect `policyStatus`, `policyVerification`, `payoutTable`, `confidence`, `sourceRefs`, and `explanationCodes` on every engine-produced result, and obtain explicit operator rulings for DNPs, reboots, ties, rescues, voids, and corrections. The three policy metadata properties are optional in the public `DfsSettlementResult` type so legacy external result objects remain source-compatible, but `createDfsEngine()` always populates them at runtime. + +```ts +const policies = createDfsEngine().getBookPolicies(); +// Immutable snapshots with status, verification, sources, and play types. +``` + +`createDfsEngine()` returns `DfsEngineWithPolicySnapshots`, which extends the legacy `DfsEngine` interface with `getBookPolicies()`. Existing external `DfsEngine` implementations do not need to add the discovery method. + +Draft fixtures are source metadata for future work. They are not registered by `createDfsEngine()` and cannot settle entries unless a caller deliberately supplies a complete custom implementation. + ## Examples ### 1. Look up the payout for a pick count + hit count @@ -144,15 +164,15 @@ Optional SDK packages: ```ts import { lookupStandardMultiplier } from '@buzzr/dfs-engine'; -// PrizePicks 5-pick Power, all five hit → 20×. +// PrizePicks compatibility table effective 2026-07-02: 5-pick Power, 5/5 → 20×. lookupStandardMultiplier({ app: 'prizepicks', playType: 'power', pickCount: 5, hits: 5 }); // → 20 -// PrizePicks 6-pick Flex, only 5 of 6 hit → 1.75×. +// PrizePicks compatibility table effective 2026-07-02: 6-pick Flex, 5/6 → 2×. lookupStandardMultiplier({ app: 'prizepicks', playType: 'flex', pickCount: 6, hits: 5 }); -// → 1.75 +// → 2 -// Underdog 8-pick Standard, all hit → 100×. +// Unverified Underdog compatibility snapshot: 8-pick Standard, 8/8 → 100×. lookupStandardMultiplier({ app: 'underdog', playType: 'underdog_standard', pickCount: 8, hits: 8 }); // → 100 ``` @@ -162,9 +182,8 @@ lookupStandardMultiplier({ app: 'underdog', playType: 'underdog_standard', pickC ```ts import { recalcMultiplierAfterDnp } from '@buzzr/dfs-engine'; -// One leg on a 6-pick Power scratched. Demote to a 5-pick (all surviving -// must hit), scaling the slip's original multiplier proportionally so -// any boost flows through. +// Under the selected compatibility profile, remove one leg from a 6-pick +// Power entry and scale the slip's displayed multiplier proportionally. const { newMultiplier } = recalcMultiplierAfterDnp({ app: 'prizepicks', playType: 'power', @@ -227,7 +246,8 @@ const result = gradeDfsBetFromGraded({ baseMultiplier: 10, profitBoostPct: null, }); -// 4-of-5 Underdog Flex → standard 2×; scaled by displayed/base ratio. +// Unverified Underdog compatibility snapshot: 4-of-5 Flex → 2×, +// scaled by displayed/base ratio. // → { status: 'won', effectiveMultiplier: 2.3, totalPayout: 23, // withdrawablePayout: 20, bonusPayout: 3 } ``` @@ -284,7 +304,7 @@ if (!grade.ok) { | Module | Highlights | |---|---| -| `payouts` | `lookupStandardMultiplier`, `recalcMultiplierAfterDnp`, `lookupBaseMultiplier` — full PrizePicks (Power/Flex) and Underdog (Standard/Flex) payout schedules | +| `payouts` | `lookupStandardMultiplier`, `recalcMultiplierAfterDnp`, `lookupBaseMultiplier` — reference compatibility schedules; PrizePicks includes reviewed standard rates effective 2026-07-02, while Underdog remains unverified | | `grading` | `gradeLegFromActual` (+`Explained`), `gradeDfsBetFromGraded`, `applyLegDnp`, `computeBoostSplit`, `detectMidGameDnp`, `reconcileMidGameDnpEntries`, `findGameLogCandidates`, `shouldRegradeLeg`, `extractStatForProp` (+`Explained`) | | `prop-normalizer` | `normalizeDfsPropType`, `asDfsPropTypeKey`, `DFS_PROP_TYPE_KEYS` | | `stat-adapters` | `getStatAdapter`, `extractStatForPropViaRegistry`, **`registerLeague`** / **`unregisterLeague`** / **`getRegisteredLeagues`**, plus per-sport tables: `BASKETBALL_ADAPTERS`, `NFL_ADAPTERS`, `MLB_ADAPTERS`, `NHL_ADAPTERS` | @@ -347,15 +367,28 @@ v4 settlement inputs are canonical: use `actual` on `DfsLegInput`, `status` for ## Status & caveats -- **Payout tables current as of 2026-05.** PrizePicks and Underdog adjust their schedules periodically; if a recalc looks wrong, check whether the published schedule changed. -- **Slip-displayed multiplier always wins.** Tables are only the demotion ratio baseline — Demon/Goblin/boost markups aren't enumerated. +- **Compatibility status is part of the result.** PrizePicks is experimental and partially verified; Underdog is experimental and unverified. Neither profile certifies a current operator ruling. +- **PrizePicks standard references were reviewed 2026-07-16.** The current standard table is effective 2026-07-02, and older effective-dated tables remain for historical entries. Recheck the linked first-party sources before relying on a later entry. +- **Displayed slip terms are authoritative.** Tables are compatibility inputs and demotion-ratio baselines; variable, promotional, state-specific, and entry-specific terms may differ. - **Gamelog parsing is your problem.** This package grades stats; it doesn't fetch them. Adapt ESPN, your own scraper, or a paid data feed to `PlayerGameLogEntryShape` upstream. - **Sport coverage:** NBA / WNBA / NCAAM (basketball), NFL, MLB (batters + pitchers), NHL (skaters + goalies). Adding a sport means a new `AdapterTable` plus extending `DfsPropTypeKey`. ## Origin -Extracted from [Buzzr](https://buzzr.app), where it settles user bets placed on PrizePicks and Underdog. The Buzzr team has been iterating on this math against real slips and real stat-correction edge cases for two years. The npm package is the same code, just decoupled from the app. +Extracted from [Buzzr](https://apps.apple.com/us/app/buzzr-sports/id6760628256) and maintained in this public monorepo. The mobile app currently vendors the 5.0.0 engine tarball; later public-repository changes require a deliberate app upgrade. Operator-named policy outputs remain compatibility estimates subject to the displayed entry terms and current operator ruling. + +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/dfs-engine`. +Deep `src/*` and `dist/*` imports are unsupported. See the +[all-package API index](../../docs/api-reference.md) and the +[generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-engine.html). + +Report reproducible defects in [GitHub Issues](https://github.com/Buzzr-app/dfs-engine/issues). +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. ## License -MIT © Sarvesh Chidambaram +[MIT](../../LICENSE) © Sarvesh Chidambaram diff --git a/packages/dfs-engine/package.json b/packages/dfs-engine/package.json index 574177b..19e14fc 100644 --- a/packages/dfs-engine/package.json +++ b/packages/dfs-engine/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-engine", - "version": "5.0.0", + "version": "5.1.0", "description": "Buzzr DFS Settlement OS core: extensible book policies, pure grading, and provider-ready settlement orchestration.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -22,11 +22,11 @@ ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --project tsconfig.type-tests.json", "test": "vitest run", "test:watch": "vitest", "bench": "vitest bench --run", - "docs": "typedoc", + "docs": "npm run docs --prefix ../..", "lint": "eslint src tests", "format": "prettier --write src tests examples", "format:check": "prettier --check src tests examples", diff --git a/packages/dfs-engine/src/batch.ts b/packages/dfs-engine/src/batch.ts index a986f67..7deb110 100644 --- a/packages/dfs-engine/src/batch.ts +++ b/packages/dfs-engine/src/batch.ts @@ -188,10 +188,13 @@ export async function runBatchSettlement( const scoped = cache.scope(); try { const result = await settle(input, settlementContext, scoped); - if (scoped.hitCount > 0 && !result.explanationCodes.includes('batch_cache_hit')) { - result.explanationCodes.push('batch_cache_hit'); - } - slots[index] = result; + slots[index] = + scoped.hitCount > 0 && !result.explanationCodes.includes('batch_cache_hit') + ? { + ...result, + explanationCodes: [...result.explanationCodes, 'batch_cache_hit'], + } + : result; } catch (error) { failures.push({ entryId: input.entryId, diff --git a/packages/dfs-engine/src/engine.ts b/packages/dfs-engine/src/engine.ts index 8fdf67d..0abbdee 100644 --- a/packages/dfs-engine/src/engine.ts +++ b/packages/dfs-engine/src/engine.ts @@ -1,5 +1,6 @@ -import { computeBoostSplit, extractStatForProp, gradeLegFromActual } from './grading'; +import { computeBoostSplit, findGameLogCandidates, gradeLegFromActual } from './grading'; import type { BetStatus, PlayerGameLogEntryShape } from './grading'; +import { extractStatForPropExplained } from './stat-adapters'; import { normalizeDfsPropType } from './prop-normalizer'; import { DfsDefinitionError, DfsEngineInvariantError } from './errors'; import { runBatchSettlement } from './batch'; @@ -31,6 +32,7 @@ export type BuiltInBookId = 'prizepicks' | 'underdog'; export type DfsBookId = BuiltInBookId | string; export type DfsPlayTypeId = string; export type DfsPolicyStatus = 'stable' | 'draft' | 'experimental'; +export type DfsPolicyVerificationStatus = 'verified' | 'partial' | 'unverified'; export type DfsPayoutModel = 'fixed-table' | 'displayed-multiplier' | 'custom'; export type DfsSettlementConfidence = 'high' | 'medium' | 'low'; export type DfsValidationSeverity = 'allow' | 'warn' | 'error'; @@ -43,6 +45,19 @@ export type DfsBookSourceRef = { note?: string; }; +export type DfsPolicyVerification = { + status: DfsPolicyVerificationStatus; + reviewedAt?: string; + notes?: readonly string[]; +}; + +export type DfsSelectedPayoutTable = { + version: string | null; + effectiveFrom: string; + sourceNotes: string[]; + sources: DfsBookSourceRef[]; +}; + export type DfsBookPlayType = { id: DfsPlayTypeId; displayName: string; @@ -70,7 +85,11 @@ export type DfsTiePolicy = }; export type DfsDnpPolicy = - | { type: 'remove_leg'; voidIfNoSurvivors?: boolean } + | { + type: 'remove_leg'; + voidIfNoSurvivors?: boolean; + refundIfBelowMinimum?: boolean; + } | { type: 'loss' } | { type: 'manual'; reasonCode?: string } | { @@ -158,14 +177,18 @@ export type DfsPayoutResolution = { payout: DfsPayoutSplit; explanationCode: string; confidence: DfsSettlementConfidence; + /** Additive audit metadata for fixed-table resolutions. */ + payoutTable?: DfsSelectedPayoutTable | null; }; export type DfsBookPolicy = { id: DfsBookId; displayName: string; version: string; + /** ISO timestamp or date-only value; date-only values begin at 00:00:00 UTC. */ effectiveFrom: string; status: DfsPolicyStatus; + verification?: DfsPolicyVerification; sources: readonly DfsBookSourceRef[]; playTypes: readonly DfsBookPlayType[]; tiePolicy: DfsTiePolicy; @@ -177,10 +200,23 @@ export type DfsBookPolicy = { payoutResolver?: (input: DfsPayoutResolverInput) => DfsPayoutResolverResult; }; +export type DfsBookPolicySnapshot = Readonly<{ + id: DfsBookId; + displayName: string; + version: string; + effectiveFrom: string; + status: DfsPolicyStatus; + verification: Readonly | null; + sources: readonly Readonly[]; + playTypes: readonly Readonly[]; +}>; + export type DfsPayoutTableEntry = { picks?: number; pickCount?: number; hits: number; + /** Optional discriminator for payout rows that apply only after tied legs. */ + pushes?: number; multiplier: number; }; @@ -188,8 +224,10 @@ export type DfsPayoutTableDefinition = { bookId: DfsBookId; playTypeId: DfsPlayTypeId; version?: string; + /** ISO timestamp or date-only value; date-only values begin at 00:00:00 UTC. */ effectiveFrom: string; sourceNotes?: readonly string[]; + sources?: readonly DfsBookSourceRef[]; entries: readonly DfsPayoutTableEntry[]; }; @@ -427,7 +465,21 @@ export type DfsSettlementResult = { adjustments: DfsSettlementAdjustment[]; pendingReasons: string[]; policyVersion: string | null; + /** + * Policy metadata added after v5.0. Optional so externally constructed + * legacy results remain source-compatible; engine-produced results always + * include this field. + */ + policyStatus?: DfsPolicyStatus | null; + /** @see DfsSettlementResult.policyStatus */ + policyVerification?: DfsPolicyVerification | null; sourceRefs: DfsBookSourceRef[]; + /** + * The fixed payout table selected for this settlement's deterministic + * as-of. Optional for legacy object compatibility; engine-produced results + * always include this field. + */ + payoutTable?: DfsSelectedPayoutTable | null; confidence: DfsSettlementConfidence; explanationCodes: string[]; validation: DfsValidationResult; @@ -478,6 +530,14 @@ export interface DfsEngine { getRegisteredBooks(): DfsBookId[]; } +/** + * Engine returned by {@link createDfsEngine}, including immutable policy + * snapshots added after the original v5 `DfsEngine` contract. + */ +export interface DfsEngineWithPolicySnapshots extends DfsEngine { + getBookPolicies(): readonly DfsBookPolicySnapshot[]; +} + export type DfsPayoutLookupInput = { bookId: DfsBookId; playTypeId: DfsPlayTypeId; @@ -504,6 +564,8 @@ export type DfsPayoutLookupInput = { * - `void_no_survivors` — every leg was removed (at least one DNP/void) and * the entry voided with a stake refund. * - `refund_no_survivors` — every leg pushed and the stake was refunded. + * - `refund_below_minimum` — a DNP left too few active picks and the stake + * was refunded by policy. * - `rescue_applied` — a leg arrived with a `rescued` status and was * excluded from payout math. * - `payout_table_lookup` — the payout came from a fixed payout table. @@ -517,6 +579,7 @@ export type DfsV5ExplanationCode = | 'push_leg_removed' | 'void_no_survivors' | 'refund_no_survivors' + | 'refund_below_minimum' | 'rescue_applied' | 'payout_table_lookup' | 'payout_displayed_multiplier' @@ -531,7 +594,7 @@ const PAYOUT_MODEL_EXPLANATION_CODES: Record = { - prizepicks: [ - { - label: 'PrizePicks payout and settlement compatibility profile', - note: 'Stable built-in profile matching @buzzr/dfs-engine v2 behavior.', - }, - ], - underdog: [ - { - label: 'Underdog payout and settlement compatibility profile', - note: 'Stable built-in profile matching @buzzr/dfs-engine v2 behavior.', - }, - ], + prizepicks: [...PRIZEPICKS_CURRENT_SOURCES], + underdog: [...UNDERDOG_LEGAL_SOURCES], }; const DEFAULT_PAYOUT_TABLES: readonly DfsPayoutTableDefinition[] = [ @@ -602,20 +707,57 @@ const DEFAULT_PAYOUT_TABLES: readonly DfsPayoutTableDefinition[] = [ playTypeId: 'power', version: '2026-05', effectiveFrom: '2026-05-01', - entries: PRIZEPICKS_POWER, + sourceNotes: [ + 'Historical @buzzr/dfs-engine compatibility snapshot; no current source assertion.', + ], + sources: [], + entries: PRIZEPICKS_POWER_2026_05, }, { bookId: 'prizepicks', playTypeId: 'flex', version: '2026-05', effectiveFrom: '2026-05-01', - entries: PRIZEPICKS_FLEX, + sourceNotes: [ + 'Historical @buzzr/dfs-engine compatibility snapshot; no current source assertion.', + ], + sources: [], + entries: PRIZEPICKS_FLEX_2026_05, + }, + { + bookId: 'prizepicks', + playTypeId: 'power', + version: '2026-07-02-player-picks', + effectiveFrom: '2026-07-02', + sourceNotes: [ + 'PrizePicks Payouts (updated July 2, 2026): https://www.prizepicks.com/help-center/payouts', + 'Standard Player Pick rates: https://www.prizepicks.com/help-center/potential-outcomes', + 'A 2-pick Power lineup with one correct pick and one tie pays the current standard 1.5x rate.', + 'The submitted lineup details remain authoritative because PrizePicks states payouts may vary.', + ], + sources: PRIZEPICKS_CURRENT_SOURCES, + entries: PRIZEPICKS_POWER_2026_07_02, + }, + { + bookId: 'prizepicks', + playTypeId: 'flex', + version: '2026-07-02-player-picks', + effectiveFrom: '2026-07-02', + sourceNotes: [ + 'PrizePicks Payouts (updated July 2, 2026): https://www.prizepicks.com/help-center/payouts', + 'Standard Player Pick rates: https://www.prizepicks.com/help-center/potential-outcomes', + 'The submitted lineup details remain authoritative because PrizePicks states payouts may vary.', + ], + sources: PRIZEPICKS_CURRENT_SOURCES, + entries: PRIZEPICKS_FLEX_2026_07_02, }, { bookId: 'underdog', playTypeId: 'underdog_standard', version: '2026-05', effectiveFrom: '2026-05-01', + sourceNotes: ['Unverified compatibility snapshot; the submitted lineup remains authoritative.'], + sources: UNDERDOG_LEGAL_SOURCES, entries: UNDERDOG_STANDARD, }, { @@ -623,6 +765,8 @@ const DEFAULT_PAYOUT_TABLES: readonly DfsPayoutTableDefinition[] = [ playTypeId: 'underdog_flex', version: '2026-05', effectiveFrom: '2026-05-01', + sourceNotes: ['Unverified compatibility snapshot; the submitted lineup remains authoritative.'], + sources: UNDERDOG_LEGAL_SOURCES, entries: UNDERDOG_FLEX, }, ]; @@ -633,7 +777,15 @@ const DEFAULT_BOOK_POLICIES: readonly DfsBookPolicy[] = [ displayName: 'PrizePicks', version: '2026-05', effectiveFrom: '2026-05-01', - status: 'stable', + status: 'experimental', + verification: { + status: 'partial', + reviewedAt: '2026-07-16', + notes: [ + 'Standard Player Pick payout references were reviewed from first-party sources.', + 'Settlement behavior and variable lineup-specific payouts are not fully verified.', + ], + }, sources: BUILT_IN_SOURCES.prizepicks, playTypes: [ { @@ -654,7 +806,11 @@ const DEFAULT_BOOK_POLICIES: readonly DfsBookPolicy[] = [ }, ], tiePolicy: { type: 'push' }, - dnpPolicy: { type: 'remove_leg', voidIfNoSurvivors: true }, + dnpPolicy: { + type: 'remove_leg', + voidIfNoSurvivors: true, + refundIfBelowMinimum: true, + }, pushPolicy: { type: 'remove_leg', refundIfNoSurvivors: true }, payoutSplit: { type: 'all_withdrawable' }, validation: { @@ -668,7 +824,15 @@ const DEFAULT_BOOK_POLICIES: readonly DfsBookPolicy[] = [ displayName: 'Underdog', version: '2026-05', effectiveFrom: '2026-05-01', - status: 'stable', + status: 'experimental', + verification: { + status: 'unverified', + reviewedAt: '2026-07-16', + notes: [ + 'The legal center was recorded as a rules entrypoint.', + 'Current payout and settlement compatibility values have not been verified.', + ], + }, sources: BUILT_IN_SOURCES.underdog, playTypes: [ { @@ -859,6 +1023,32 @@ export function defineBookPolicy(policy: DfsBookPolicy): DfsBookPolicy { if (!['stable', 'draft', 'experimental'].includes(policy.status)) { throw new DfsDefinitionError('defineBookPolicy: status is invalid'); } + if ( + policy.verification && + !['verified', 'partial', 'unverified'].includes(policy.verification.status) + ) { + throw new DfsDefinitionError('defineBookPolicy: verification.status is invalid'); + } + if (policy.verification?.reviewedAt != null) { + if (typeof policy.verification.reviewedAt !== 'string') { + throw new DfsDefinitionError('defineBookPolicy: verification.reviewedAt must be a string'); + } + if (!isValidDate(policy.verification.reviewedAt)) { + throw new DfsDefinitionError('defineBookPolicy: verification.reviewedAt must be parseable'); + } + } + if (policy.verification?.notes != null) { + if (!Array.isArray(policy.verification.notes)) { + throw new DfsDefinitionError('defineBookPolicy: verification.notes must be an array'); + } + for (const [index, note] of policy.verification.notes.entries()) { + if (typeof note !== 'string' || !note.trim()) { + throw new DfsDefinitionError( + `defineBookPolicy: verification.notes.${index} must be a non-empty string`, + ); + } + } + } if (!policy.playTypes.length) { throw new DfsDefinitionError('defineBookPolicy: at least one play type is required'); } @@ -890,14 +1080,31 @@ export function defineBookPolicy(policy: DfsBookPolicy): DfsBookPolicy { if (!source.label || !source.label.trim()) { throw new DfsDefinitionError(`defineBookPolicy: sources.${index}.label is required`); } - if (source.retrievedAt && !isValidDate(source.retrievedAt)) { - throw new DfsDefinitionError( - `defineBookPolicy: sources.${index}.retrievedAt must be parseable`, - ); + if (source.retrievedAt != null) { + if (typeof source.retrievedAt !== 'string') { + throw new DfsDefinitionError( + `defineBookPolicy: sources.${index}.retrievedAt must be a string`, + ); + } + if (!isValidDate(source.retrievedAt)) { + throw new DfsDefinitionError( + `defineBookPolicy: sources.${index}.retrievedAt must be parseable`, + ); + } } } return Object.freeze({ ...policy, + verification: policy.verification + ? Object.freeze({ + ...policy.verification, + notes: policy.verification.notes + ? Object.freeze([...policy.verification.notes]) + : undefined, + }) + : undefined, + // Preserve the v5 public definition API's shallow-freeze behavior. Deeply + // immutable copies are available through getBookPolicies() snapshots. sources: Object.freeze([...policy.sources]), playTypes: Object.freeze(policy.playTypes.map((playType) => Object.freeze({ ...playType }))), }); @@ -916,6 +1123,23 @@ export function definePayoutTable(table: DfsPayoutTableDefinition): DfsPayoutTab if (!table.entries.length) { throw new DfsDefinitionError('definePayoutTable: entries are required'); } + for (const [index, source] of (table.sources ?? []).entries()) { + if (!source.label || !source.label.trim()) { + throw new DfsDefinitionError(`definePayoutTable: sources.${index}.label is required`); + } + if (source.retrievedAt != null) { + if (typeof source.retrievedAt !== 'string') { + throw new DfsDefinitionError( + `definePayoutTable: sources.${index}.retrievedAt must be a string`, + ); + } + if (!isValidDate(source.retrievedAt)) { + throw new DfsDefinitionError( + `definePayoutTable: sources.${index}.retrievedAt must be parseable`, + ); + } + } + } const rows = new Set(); for (const entry of table.entries) { const picks = tableEntryPicks(entry); @@ -929,12 +1153,17 @@ export function definePayoutTable(table: DfsPayoutTableDefinition): DfsPayoutTab 'definePayoutTable: hits must be an integer between 0 and pickCount', ); } + if (entry.pushes != null && (!Number.isInteger(entry.pushes) || entry.pushes < 0)) { + throw new DfsDefinitionError( + 'definePayoutTable: pushes must be a non-negative integer when provided', + ); + } if (!Number.isFinite(entry.multiplier) || entry.multiplier <= 0) { throw new DfsDefinitionError( 'definePayoutTable: multiplier must be a finite positive number', ); } - const key = `${picks}:${entry.hits}`; + const key = `${picks}:${entry.hits}:pushes=${entry.pushes ?? 'any'}`; if (rows.has(key)) { throw new DfsDefinitionError(`definePayoutTable: duplicate payout row ${key}`); } @@ -942,6 +1171,10 @@ export function definePayoutTable(table: DfsPayoutTableDefinition): DfsPayoutTab } return Object.freeze({ ...table, + sourceNotes: table.sourceNotes ? Object.freeze([...table.sourceNotes]) : undefined, + sources: table.sources + ? Object.freeze(table.sources.map((source) => Object.freeze({ ...source }))) + : undefined, entries: Object.freeze(table.entries.map((entry) => Object.freeze({ ...entry }))), }); } @@ -1031,7 +1264,7 @@ function adaptBuzzrLeg(leg: DfsBetLeg): DfsLegInput { }; } -export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { +export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngineWithPolicySnapshots { const clock = config.clock ?? (() => new Date()); const bookPolicies = new Map(); const payoutTables: DfsPayoutTableDefinition[] = DEFAULT_PAYOUT_TABLES.map((table) => ({ @@ -1084,6 +1317,14 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { return [...bookPolicies.keys()]; } + function getBookPolicies(): readonly DfsBookPolicySnapshot[] { + return Object.freeze( + [...bookPolicies.values()] + .sort((left, right) => String(left.id).localeCompare(String(right.id))) + .map(snapshotBookPolicy), + ); + } + function resolvePolicy( entry: Pick, ): { policy: DfsBookPolicy; playType: DfsBookPlayType } | null { @@ -1101,11 +1342,22 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { function findPayoutTable( bookId: DfsBookId, playTypeId: DfsPlayTypeId, + asOf: string, ): DfsPayoutTableDefinition | null { + const matching = payoutTables + .map((table, index) => ({ table, index })) + .filter(({ table }) => table.bookId === bookId && table.playTypeId === playTypeId); + const asOfTimestamp = parseEffectiveTimestamp(asOf); + const eligible = matching.filter( + ({ table }) => parseEffectiveTimestamp(table.effectiveFrom) <= asOfTimestamp, + ); + return ( - [...payoutTables] - .reverse() - .find((table) => table.bookId === bookId && table.playTypeId === playTypeId) ?? null + eligible.sort((left, right) => { + const effectiveDelta = + Date.parse(right.table.effectiveFrom) - Date.parse(left.table.effectiveFrom); + return effectiveDelta !== 0 ? effectiveDelta : right.index - left.index; + })[0]?.table ?? null ); } @@ -1114,28 +1366,51 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { playTypeId: DfsPlayTypeId, picks: number, hits: number, + asOf: string, + outcomes: { pushes?: number } = {}, ): number | null { - const table = findPayoutTable(bookId, playTypeId); + const table = findPayoutTable(bookId, playTypeId, asOf); if (!table) { return null; } - return ( - table.entries.find((entry) => tableEntryPicks(entry) === picks && entry.hits === hits) - ?.multiplier ?? null + const candidates = table.entries.filter( + (entry) => tableEntryPicks(entry) === picks && entry.hits === hits, + ); + const outcomeSpecific = candidates.find( + (entry) => entry.pushes != null && entry.pushes === (outcomes.pushes ?? 0), ); + const generic = candidates.find((entry) => entry.pushes == null); + return (outcomeSpecific ?? generic)?.multiplier ?? null; } - function resolveBaseMultiplier(input: DfsPayoutLookupInput): number | null { + function resolveBaseMultiplier(input: DfsPayoutLookupInput, asOf: string): number | null { if (input.baseMultiplier != null) { return input.baseMultiplier; } - return lookupTableMultiplier(input.bookId, input.playTypeId, input.pickCount, input.pickCount); + const originalPickCount = input.entry?.legs.length || input.pickCount; + return lookupTableMultiplier( + input.bookId, + input.playTypeId, + originalPickCount, + originalPickCount, + asOf, + ); } - function normalizeEntry(input: DfsEntryInput): DfsEntryInput { + function normalizeEntry( + input: DfsEntryInput, + fallbackAsOf = clock().toISOString(), + ): DfsEntryInput { + const payoutAsOf = input.placedAt ?? fallbackAsOf; const baseMultiplier = input.baseMultiplier ?? - lookupTableMultiplier(input.bookId, input.playTypeId, input.legs.length, input.legs.length); + lookupTableMultiplier( + input.bookId, + input.playTypeId, + input.legs.length, + input.legs.length, + payoutAsOf, + ); return { ...input, baseMultiplier, @@ -1249,51 +1524,73 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { row, ); } - const localActual = runEngineLeagueAdapter(leg, row); - if (localActual.status === 'invalid') { - return statFailure( - 'invalid_adapter_result', - 'stat_provider', - provider.id, - 'league-adapter', - context.settledAt, - row, - ); - } - if (localActual.status === 'ok') { - return { - ok: true, - actual: localActual.actual, - value: localActual.actual, - source: 'stat_provider', + } + const candidates = findGameLogCandidates(leg.gameDate ?? null, rows, { + assumeFirst: rows.length === 1, + }); + if (candidates.length !== 1) { + return statFailure( + 'missing_provider_data', + 'stat_provider', + provider.id, + provider.id, + context.settledAt, + rows, + ); + } + const row = candidates[0]; + const localActual = runEngineLeagueAdapter(leg, row); + if (localActual.status === 'invalid') { + return statFailure( + 'invalid_adapter_result', + 'stat_provider', + provider.id, + 'league-adapter', + context.settledAt, + row, + ); + } + if (localActual.status === 'ok') { + return { + ok: true, + actual: localActual.actual, + value: localActual.actual, + source: 'stat_provider', + providerId: provider.id, + provenance: { + source: 'league-adapter', providerId: provider.id, - provenance: { - source: 'league-adapter', - providerId: provider.id, - observedAt: context.settledAt, - confidence: 1, - raw: row, - }, - }; - } - const actual = runAdapter(leg, row, entry.bookId); - if (actual != null) { - return { - ok: true, - actual, - value: actual, - source: 'stat_provider', + observedAt: context.settledAt, + confidence: 1, + raw: row, + }, + }; + } + const adapted = runAdapter(leg, row, entry.bookId); + if (adapted.ok) { + return { + ok: true, + actual: adapted.actual, + value: adapted.actual, + source: 'stat_provider', + providerId: provider.id, + provenance: { + source: 'stat-provider', providerId: provider.id, - provenance: { - source: 'stat-provider', - providerId: provider.id, - observedAt: context.settledAt, - confidence: 1, - raw: row, - }, - }; - } + observedAt: context.settledAt, + confidence: 1, + raw: row, + }, + }; } + return statFailure( + localActual.supported ? 'missing_stat' : adapted.reason, + 'stat_provider', + provider.id, + provider.id, + context.settledAt, + row, + ); } } catch (error) { return { @@ -1321,13 +1618,16 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { function runEngineLeagueAdapter( legInput: DfsLegInput, rawEntry: PlayerGameLogEntryShape, - ): { status: 'ok'; actual: number } | { status: 'miss' } | { status: 'invalid' } { + ): + | { status: 'ok'; actual: number } + | { status: 'miss'; supported: boolean } + | { status: 'invalid' } { const localAdapter = leagueAdapters.get(normalizeLeague(legInput.league)); const localExtractor = localAdapter?.adapters?.[normalizeDfsPropType(legInput.propType)] ?? localAdapter?.adapters?.[legInput.propType]; if (!localExtractor) { - return { status: 'miss' }; + return { status: 'miss', supported: false }; } const localActual = localExtractor(rawEntry, legInput); @@ -1335,7 +1635,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { return { status: 'ok', actual: localActual }; } if (localActual == null) { - return { status: 'miss' }; + return { status: 'miss', supported: true }; } return { status: 'invalid' }; } @@ -1386,11 +1686,11 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }; } - const actual = runAdapter(leg, providerData, entry?.bookId ?? 'prizepicks'); - if (actual == null) { + const adapted = runAdapter(leg, providerData, entry?.bookId ?? 'prizepicks'); + if (!adapted.ok) { return { ok: false, - reason: 'unsupported_prop', + reason: localActual.supported ? 'missing_stat' : adapted.reason, source: 'provider_data', provenance: { ...provenance('provider_data', undefined, context.settledAt), @@ -1401,8 +1701,8 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { return { ok: true, - actual, - value: actual, + actual: adapted.actual, + value: adapted.actual, source: 'provider_data', provenance: { ...provenance('provider_data', undefined, context.settledAt), @@ -1440,6 +1740,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { if (!resolved) { return null; } + const asOf = pseudoEntry.placedAt ?? clock().toISOString(); return lookupPayoutForPolicy( { ...input, @@ -1451,6 +1752,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }, resolved.policy, resolved.playType, + asOf, ); } @@ -1599,7 +1901,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }); } - const entry = normalizeEntry(input); + const entry = normalizeEntry(input, settledAt); const resolved = resolvePolicy(entry); if (!resolved) { return pendingResult({ @@ -1614,6 +1916,26 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { } const { policy, playType } = resolved; + const policyVerification = copyPolicyVerification(policy.verification); + const payoutAsOf = entry.placedAt ?? settledAt; + const payoutTable = selectedPayoutTable( + playType.payoutModel === 'fixed-table' + ? findPayoutTable(entry.bookId, entry.playTypeId, payoutAsOf) + : null, + policy, + ); + auditTrail.push({ + at: settledAt, + code: 'settlement.policy_selected', + message: `Selected ${policy.id}/${playType.id} policy for settlement.`, + metadata: { + policyVersion: policy.version, + policyStatus: policy.status, + policyVerification, + payoutAsOf, + payoutTable, + }, + }); const decisions: DfsLegDecision[] = []; const adjustments: DfsSettlementAdjustment[] = []; const pendingReasons: string[] = []; @@ -1621,6 +1943,12 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { for (const warning of validation.warnings) { explanationCodes.add(warning.code); } + if (policy.status !== 'stable') { + explanationCodes.add(`policy.status.${policy.status}`); + } + if (policy.verification && policy.verification.status !== 'verified') { + explanationCodes.add(`policy.verification.${policy.verification.status}`); + } for (const leg of entry.legs) { const contextStatus = context.legStatusesByLegId?.[leg.legId] ?? leg.status; @@ -1677,6 +2005,34 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { continue; } + if (contextStatus === 'won' || contextStatus === 'lost' || contextStatus === 'push') { + const status = + contextStatus === 'push' + ? resolvePushOutcome(policy, leg, entry, context) + : contextStatus; + decisions.push({ + legId: leg.legId, + status, + actual: context.actualsByLegId?.[leg.legId] ?? leg.actual ?? null, + line: leg.line, + direction: leg.direction, + playerName: leg.playerName, + propType: leg.propType, + provider: provenance('status', context.providerId, settledAt), + pendingReason: null, + }); + if (status === 'push') { + adjustments.push({ + type: 'push', + legId: leg.legId, + message: `${leg.playerName} pushed and was removed by policy.`, + }); + explanationCodes.add('leg.push_removed'); + explanationCodes.add('push_leg_removed'); + } + continue; + } + const stat = await extractLegStat(leg, context, entry, cache); if (!stat.ok) { decisions.push({ @@ -1748,7 +2104,10 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { adjustments, pendingReasons, policyVersion: policy.version, + policyStatus: policy.status, + policyVerification, sourceRefs: [...policy.sources], + payoutTable, confidence: 'low', explanationCodes: [...explanationCodes], validation, @@ -1764,22 +2123,41 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { const removedStatuses = new Set(['dnp', 'push', 'void', 'rescued', 'canceled']); const active = decisions.filter((decision) => !removedStatuses.has(decision.status)); const removed = decisions.length - active.length; - if (active.length === 0) { + const dnpCount = decisions.filter((decision) => decision.status === 'dnp').length; + const refundBelowMinimum = + active.length > 0 && + active.length < playType.pickCount.min && + dnpCount > 0 && + policy.dnpPolicy.type === 'remove_leg' && + policy.dnpPolicy.refundIfBelowMinimum === true; + if (active.length === 0 || refundBelowMinimum) { + const allPushed = + !refundBelowMinimum && decisions.every((decision) => decision.status === 'push'); adjustments.push({ - type: 'void', - message: 'All legs were removed by policy, so the entry returns stake.', + type: allPushed ? 'push' : 'void', + message: refundBelowMinimum + ? `A DNP left fewer than ${playType.pickCount.min} active picks, so the entry returns stake.` + : allPushed + ? 'All legs pushed, so the entry returns stake.' + : 'All legs were removed by policy, so the entry returns stake.', }); - explanationCodes.add('settlement.all_legs_removed_refund'); explanationCodes.add( - decisions.every((decision) => decision.status === 'push') - ? 'refund_no_survivors' - : 'void_no_survivors', + refundBelowMinimum + ? 'settlement.below_minimum_refund' + : 'settlement.all_legs_removed_refund', + ); + explanationCodes.add( + refundBelowMinimum + ? 'refund_below_minimum' + : allPushed + ? 'refund_no_survivors' + : 'void_no_survivors', ); return { entryId: entry.entryId, bookId: entry.bookId, playTypeId: entry.playTypeId, - status: 'void', + status: allPushed ? 'pushed' : 'void', multiplier: 1, effectiveMultiplier: 1, payout: splitAllWithdrawable(entry.stake), @@ -1791,8 +2169,11 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { adjustments, pendingReasons, policyVersion: policy.version, + policyStatus: policy.status, + policyVerification, sourceRefs: [...policy.sources], - confidence: 'high', + payoutTable, + confidence: capPolicyConfidence('high', policy), explanationCodes: [...explanationCodes], validation, provenance: { @@ -1804,8 +2185,12 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { ...auditTrail, { at: settledAt, - code: 'settlement.void', - message: 'Entry voided because no active legs remained.', + code: allPushed ? 'settlement.pushed' : 'settlement.void', + message: refundBelowMinimum + ? 'Entry voided because a DNP left fewer than the minimum active picks.' + : allPushed + ? 'Entry pushed because every leg pushed.' + : 'Entry voided because no active legs remained.', }, ], }; @@ -1832,16 +2217,24 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }, policy, playType, + payoutAsOf, ) ?? { status: losses > 0 ? 'lost' : 'pending', multiplier: 0, payout: EMPTY_PAYOUT, explanationCode: 'settlement.no_payout_resolution', confidence: 'low' as const, + payoutTable, }; explanationCodes.add(payout.explanationCode); explanationCodes.add(PAYOUT_MODEL_EXPLANATION_CODES[playType.payoutModel]); + if ( + payout.status === 'pending' && + payout.explanationCode === 'settlement.no_payout_table_row' + ) { + pendingReasons.push('missing_payout_table_row'); + } if (removed > 0) { explanationCodes.add('settlement.repriced_after_removed_legs'); adjustments.push({ @@ -1859,6 +2252,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { hits, losses, removed, + payoutTable: payout.payoutTable ?? null, }, }); @@ -1878,8 +2272,11 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { adjustments, pendingReasons, policyVersion: policy.version, + policyStatus: policy.status, + policyVerification, sourceRefs: [...policy.sources], - confidence: payout.confidence, + payoutTable: payout.payoutTable ?? null, + confidence: capPolicyConfidence(payout.confidence, policy), explanationCodes: [...explanationCodes], validation, provenance: { @@ -1918,7 +2315,20 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }, policy: DfsBookPolicy, playType: DfsBookPlayType, + asOf: string, ): DfsPayoutResolution | null { + const matchingTables = payoutTables.some( + (table) => table.bookId === input.bookId && table.playTypeId === input.playTypeId, + ); + const tableDefinition = + playType.payoutModel === 'fixed-table' + ? findPayoutTable(input.bookId, input.playTypeId, asOf) + : null; + const payoutTable = selectedPayoutTable(tableDefinition, policy); + if (playType.payoutModel === 'fixed-table' && matchingTables && !tableDefinition) { + return null; + } + if (playType.allOrNothing && input.losses > 0) { return { status: 'lost', @@ -1926,6 +2336,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { payout: EMPTY_PAYOUT, explanationCode: 'settlement.all_or_nothing_loss', confidence: 'high', + payoutTable, }; } @@ -1949,7 +2360,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { decisions: input.decisions, }); assertFiniteNonNegative(resolved.multiplier, 'custom payout multiplier'); - const payout = + const rawPayout = resolved.payout ?? splitPayout(policy, { stake: input.stake, @@ -1958,13 +2369,15 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { baseMultiplier: input.baseMultiplier, profitBoostPct: input.profitBoostPct, }); - assertPayoutSplit(payout, 'custom payout'); + assertPayoutSplit(rawPayout, 'custom payout'); + const payout = roundPayoutSplit(rawPayout); return { status: resolved.multiplier > 0 ? 'won' : 'lost', multiplier: resolved.multiplier, payout, explanationCode: resolved.explanationCode ?? 'settlement.custom_payout', confidence: resolved.confidence ?? 'medium', + payoutTable: null, }; } @@ -1982,23 +2395,40 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { input.playTypeId, input.pickCount, input.hits, + asOf, + { pushes: input.pushes }, ); if (tableMultiplier == null) { multiplier = 0; explanationCode = 'settlement.no_payout_table_row'; } else if (playType.scaleDisplayedMultiplier && input.displayedMultiplier != null) { const baseAllHit = - resolveBaseMultiplier(input) ?? - lookupTableMultiplier(input.bookId, input.playTypeId, input.pickCount, input.pickCount); + resolveBaseMultiplier(input, asOf) ?? + lookupTableMultiplier( + input.bookId, + input.playTypeId, + input.pickCount, + input.pickCount, + asOf, + ); const allHit = lookupTableMultiplier( input.bookId, input.playTypeId, input.pickCount, input.pickCount, + asOf, + ); + const originalPickCount = input.entry.legs.length || input.pickCount; + const originalAllHit = lookupTableMultiplier( + input.bookId, + input.playTypeId, + originalPickCount, + originalPickCount, + asOf, ); if (baseAllHit && allHit) { multiplier = (input.displayedMultiplier * tableMultiplier) / baseAllHit; - baseForSplit = (baseAllHit * tableMultiplier) / allHit; + baseForSplit = (baseAllHit * tableMultiplier) / (originalAllHit ?? allHit); } else { multiplier = tableMultiplier; baseForSplit = tableMultiplier; @@ -2016,6 +2446,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { payout: EMPTY_PAYOUT, explanationCode, confidence: 'high', + payoutTable, }; } @@ -2028,11 +2459,17 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { }); assertPayoutSplit(payout, 'payout'); return { - status: multiplier > 0 ? 'won' : 'lost', + status: + multiplier > 0 + ? 'won' + : explanationCode === 'settlement.no_payout_table_row' && input.losses === 0 + ? 'pending' + : 'lost', multiplier, payout, explanationCode, confidence: explanationCode === 'settlement.no_payout_table_row' ? 'low' : 'high', + payoutTable, }; } @@ -2050,6 +2487,7 @@ export function createDfsEngine(config: DfsEngineConfig = {}): DfsEngine { registerLeagueAdapter, registerStatProvider, getRegisteredBooks, + getBookPolicies, }; } @@ -2069,25 +2507,28 @@ function splitPayout( if (policy.payoutSplit.type === 'custom') { const split = policy.payoutSplit.split(input); assertPayoutSplit(split, 'custom payout split'); - return split; + return roundPayoutSplit(split); } if (policy.payoutSplit.type === 'underdog_bonus_split') { - return computeBoostSplit({ - app: 'underdog', - totalPayout: input.totalPayout, - stake: input.stake, - multiplier: input.multiplier, - baseMultiplier: input.baseMultiplier ?? input.multiplier, - profitBoostPct: input.profitBoostPct, - }); + return roundPayoutSplit( + computeBoostSplit({ + app: 'underdog', + totalPayout: input.totalPayout, + stake: input.stake, + multiplier: input.multiplier, + baseMultiplier: input.baseMultiplier ?? input.multiplier, + profitBoostPct: input.profitBoostPct, + }), + ); } return splitAllWithdrawable(input.totalPayout); } function splitAllWithdrawable(total: number): DfsPayoutSplit { + const rounded = roundMoney(total); return { - total, - withdrawable: total, + total: rounded, + withdrawable: rounded, bonus: 0, }; } @@ -2186,13 +2627,20 @@ function runAdapter( leg: DfsLegInput, rawEntry: PlayerGameLogEntryShape, bookId: DfsBookId, -): number | null { - const league = normalizeLeague(leg.league); - const value = extractStatForProp(leg.propType, league, rawEntry, bookId as DfsApp); - if (typeof value === 'number' && Number.isFinite(value)) { - return value; +): { ok: true; actual: number } | { ok: false; reason: 'missing_stat' | 'unsupported_prop' } { + const result = extractStatForPropExplained( + leg.propType, + normalizeLeague(leg.league), + rawEntry, + bookId as DfsApp, + ); + if (result.ok) { + return { ok: true, actual: result.value }; } - return null; + return { + ok: false, + reason: result.reason === 'adapter_returned_null' ? 'missing_stat' : 'unsupported_prop', + }; } function tableEntryPicks(entry: DfsPayoutTableEntry): number | null { @@ -2273,12 +2721,11 @@ function assertPayoutLookupInvariants(input: DfsPayoutLookupInput): void { } } const losses = input.losses ?? 0; - const pushes = input.pushes ?? 0; if (input.hits > input.pickCount) { throw new DfsEngineInvariantError('hits cannot exceed pickCount'); } - if (input.hits + losses + pushes > input.pickCount) { - throw new DfsEngineInvariantError('hits, losses, and pushes cannot exceed pickCount'); + if (input.hits + losses > input.pickCount) { + throw new DfsEngineInvariantError('hits and losses cannot exceed pickCount'); } } @@ -2304,11 +2751,116 @@ function assertFiniteNonNegative(value: number, label: string): void { } function isValidDate(value: string): boolean { - return Number.isFinite(Date.parse(value)); + return Number.isFinite(parseEffectiveTimestamp(value)); } function roundMoney(value: number): number { - return Math.round(value * 100) / 100; + return roundDecimalHalfUp(value, 2); +} + +function roundDecimalHalfUp(value: number, places: number): number { + const sign = value < 0 ? -1 : 1; + const shifted = shiftDecimalExponent(Math.abs(value), places); + const roundingTolerance = Number.EPSILON * Math.max(1, shifted); + const rounded = sign * shiftDecimalExponent(Math.round(shifted + roundingTolerance), -places); + return Object.is(rounded, -0) ? 0 : rounded; +} + +function shiftDecimalExponent(value: number, places: number): number { + const [coefficient, exponent = '0'] = value.toString().split('e'); + return Number(`${coefficient}e${Number(exponent) + places}`); +} + +function parseEffectiveTimestamp(value: string): number { + const normalized = /^\d{4}-\d{2}-\d{2}$/u.test(value) ? `${value}T00:00:00.000Z` : value; + return Date.parse(normalized); +} + +function selectedPayoutTable( + table: DfsPayoutTableDefinition | null, + policy: DfsBookPolicy, +): DfsSelectedPayoutTable | null { + if (!table) { + return null; + } + return { + version: table.version ?? null, + effectiveFrom: table.effectiveFrom, + sourceNotes: [...(table.sourceNotes ?? [])], + sources: (table.sources ?? policy.sources).map((source) => ({ ...source })), + }; +} + +function snapshotPolicyVerification( + verification: DfsPolicyVerification | undefined, +): Readonly | null { + if (!verification) { + return null; + } + return Object.freeze({ + ...verification, + notes: verification.notes ? Object.freeze([...verification.notes]) : undefined, + }); +} + +function copyPolicyVerification( + verification: DfsPolicyVerification | undefined, +): DfsPolicyVerification | null { + return verification + ? { + ...verification, + notes: verification.notes ? [...verification.notes] : undefined, + } + : null; +} + +function capPolicyConfidence( + confidence: DfsSettlementConfidence, + policy: DfsBookPolicy, +): DfsSettlementConfidence { + const ranks: Record = { low: 0, medium: 1, high: 2 }; + let maximum: DfsSettlementConfidence = 'high'; + if (policy.status === 'draft') { + maximum = 'low'; + } else if (policy.status === 'experimental') { + maximum = 'medium'; + } + if (policy.verification?.status === 'unverified') { + maximum = 'low'; + } else if (policy.verification?.status === 'partial' && ranks[maximum] > ranks.medium) { + maximum = 'medium'; + } + return ranks[confidence] <= ranks[maximum] ? confidence : maximum; +} + +function snapshotBookPolicy(policy: DfsBookPolicy): DfsBookPolicySnapshot { + return Object.freeze({ + id: policy.id, + displayName: policy.displayName, + version: policy.version, + effectiveFrom: policy.effectiveFrom, + status: policy.status, + verification: snapshotPolicyVerification(policy.verification), + sources: Object.freeze(policy.sources.map((source) => Object.freeze({ ...source }))), + playTypes: Object.freeze( + policy.playTypes.map((playType) => + Object.freeze({ + ...playType, + pickCount: Object.freeze({ ...playType.pickCount }), + }), + ), + ), + }); +} + +function roundPayoutSplit(payout: DfsPayoutSplit): DfsPayoutSplit { + const total = roundMoney(payout.total); + const bonus = Math.min(total, roundMoney(payout.bonus)); + return { + total, + withdrawable: roundMoney(total - bonus), + bonus, + }; } function pendingResult(input: { @@ -2336,7 +2888,10 @@ function pendingResult(input: { adjustments: [], pendingReasons: input.pendingReasons, policyVersion: null, + policyStatus: null, + policyVerification: null, sourceRefs: [], + payoutTable: null, confidence: 'low', explanationCodes: [ ...input.explanationCodes, diff --git a/packages/dfs-engine/src/index.ts b/packages/dfs-engine/src/index.ts index bfbfffe..f455ed9 100644 --- a/packages/dfs-engine/src/index.ts +++ b/packages/dfs-engine/src/index.ts @@ -153,10 +153,12 @@ export type { DfsBookId, DfsBookPlayType, DfsBookPolicy, + DfsBookPolicySnapshot, DfsBookSourceRef, DfsBookValidationRules, DfsDnpPolicy, DfsEngine, + DfsEngineWithPolicySnapshots, DfsEngineAuditMetadata, DfsEngineConfig, DfsEntryInput, @@ -175,8 +177,11 @@ export type { DfsPayoutSplitStrategy, DfsPayoutTableEntry, DfsPayoutTableDefinition, + DfsSelectedPayoutTable, DfsPlayTypeId, DfsPolicyStatus, + DfsPolicyVerification, + DfsPolicyVerificationStatus, DfsProviderProvenance, DfsPushPolicy, DfsRescuePolicy, diff --git a/packages/dfs-engine/src/payouts.ts b/packages/dfs-engine/src/payouts.ts index a168ed1..2a0510d 100644 --- a/packages/dfs-engine/src/payouts.ts +++ b/packages/dfs-engine/src/payouts.ts @@ -18,13 +18,13 @@ * * new_multiplier ≈ current_multiplier × (table[demoted] / table[original]) * - * - Tables current as of 2026-05. Apps adjust these periodically; if a + * - Latest standard references reviewed on 2026-07-16. Apps adjust these periodically; if a * user reports a recalc that looks wrong, the first thing to verify * is whether the published payout schedule has changed. * - * - For PrizePicks Flex 5/6 = 1.75x in our last reference, but some - * promos show 2x. The displayed multiplier always wins; this table - * is only the demotion baseline. + * - PrizePicks states payouts may vary by lineup. The displayed + * multiplier always wins; this table is only the latest standard + * demotion baseline. */ import type { DfsApp, DfsPlayType } from './types'; import { DfsEngineInvariantError } from './errors'; @@ -38,17 +38,17 @@ type PayoutSchedule = Record>; const PRIZEPICKS_POWER: PayoutSchedule = { 2: { 2: 3 }, - 3: { 3: 5 }, + 3: { 3: 6 }, 4: { 4: 10 }, 5: { 5: 20 }, 6: { 6: 37.5 }, }; const PRIZEPICKS_FLEX: PayoutSchedule = { - 3: { 3: 2.25, 2: 1.25 }, - 4: { 4: 5, 3: 1.5 }, + 3: { 3: 3, 2: 1 }, + 4: { 4: 6, 3: 1.5 }, 5: { 5: 10, 4: 2, 3: 0.4 }, - 6: { 6: 25, 5: 1.75, 4: 0.4 }, + 6: { 6: 25, 5: 2, 4: 0.4 }, }; const UNDERDOG_STANDARD: PayoutSchedule = { diff --git a/packages/dfs-engine/src/policy-validator.ts b/packages/dfs-engine/src/policy-validator.ts index 4158ae2..c8c1679 100644 --- a/packages/dfs-engine/src/policy-validator.ts +++ b/packages/dfs-engine/src/policy-validator.ts @@ -15,6 +15,7 @@ import type { DfsBookPolicy } from './engine'; import type { DfsValidationIssue, DfsValidationResult } from './validators'; const POLICY_STATUSES = ['stable', 'draft', 'experimental'] as const; +const POLICY_VERIFICATION_STATUSES = ['verified', 'partial', 'unverified'] as const; const PAYOUT_MODELS = ['fixed-table', 'displayed-multiplier', 'custom'] as const; /** @@ -75,12 +76,78 @@ export function validateBookPolicyDefinition( validatePlayTypes(policy, errors); validateSources(policy, errors); + validateVerification(policy, errors); return errors.length ? invalid(errors) : { ok: true, value: definition as unknown as DfsBookPolicy, errors: [], warnings: [] }; } +function validateVerification(policy: Record, errors: DfsValidationIssue[]): void { + if (policy.verification == null) { + return; + } + if (!isPlainObject(policy.verification)) { + errors.push( + issue( + 'policy.invalid_verification', + 'verification must be an object when provided.', + 'verification', + ), + ); + return; + } + if ( + !POLICY_VERIFICATION_STATUSES.includes( + policy.verification.status as (typeof POLICY_VERIFICATION_STATUSES)[number], + ) + ) { + errors.push( + issue( + 'policy.invalid_verification_status', + `verification.status must be one of ${POLICY_VERIFICATION_STATUSES.join(', ')}.`, + 'verification.status', + ), + ); + } + if ( + policy.verification.reviewedAt != null && + (typeof policy.verification.reviewedAt !== 'string' || + !isValidDate(policy.verification.reviewedAt)) + ) { + errors.push( + issue( + 'policy.invalid_verification_reviewed_at', + 'verification.reviewedAt must be a parseable date.', + 'verification.reviewedAt', + ), + ); + } + if (policy.verification.notes != null) { + if (!Array.isArray(policy.verification.notes)) { + errors.push( + issue( + 'policy.invalid_verification_notes', + 'verification.notes must be an array when provided.', + 'verification.notes', + ), + ); + } else { + policy.verification.notes.forEach((note, index) => { + if (typeof note !== 'string' || !note.trim()) { + errors.push( + issue( + 'policy.invalid_verification_note', + `verification.notes.${index} must be a non-empty string.`, + `verification.notes.${index}`, + ), + ); + } + }); + } + } +} + function validatePlayTypes(policy: Record, errors: DfsValidationIssue[]): void { const playTypes = policy.playTypes; if (!Array.isArray(playTypes) || playTypes.length === 0) { diff --git a/packages/dfs-engine/tests/batch-settlement.test.ts b/packages/dfs-engine/tests/batch-settlement.test.ts index a6b9e1e..7c05c11 100644 --- a/packages/dfs-engine/tests/batch-settlement.test.ts +++ b/packages/dfs-engine/tests/batch-settlement.test.ts @@ -123,6 +123,34 @@ describe('v5 batch settlement (engine.settleEntries)', () => { expect(batch.results[2]?.explanationCodes).toContain('batch_cache_hit'); }); + test('adds cache-hit evidence without mutating the settlement object saved by the engine', async () => { + const saved: Array<{ explanationCodes: string[] }> = []; + const provider = defineStatProvider({ + id: 'immutable-log', + getGameLog: () => [gameLogRow()], + }); + const engine = createDfsEngine({ + bookPolicies: [singleLegBook], + statProviders: [provider], + settlementStore: { + id: 'capture-store', + saveSettlement(result) { + saved.push(result); + }, + }, + }); + + const batch = await engine.settleEntries([ + entry({ entryId: 'immutable-a', legs: [leg({ legId: 'a-1' })] }), + entry({ entryId: 'immutable-b', legs: [leg({ legId: 'b-1' })] }), + ]); + + expect(saved).toHaveLength(2); + expect(saved[1]?.explanationCodes).not.toContain('batch_cache_hit'); + expect(batch.results[1]?.explanationCodes).toContain('batch_cache_hit'); + expect(batch.results[1]).not.toBe(saved[1]); + }); + test('does not dedupe different players, games, or leagues', async () => { let calls = 0; const provider = defineStatProvider({ diff --git a/packages/dfs-engine/tests/book-policy-registry.test.ts b/packages/dfs-engine/tests/book-policy-registry.test.ts index b5aab52..dee7014 100644 --- a/packages/dfs-engine/tests/book-policy-registry.test.ts +++ b/packages/dfs-engine/tests/book-policy-registry.test.ts @@ -66,6 +66,75 @@ const customPolicy = defineBookPolicy({ }); describe('Book Policy Registry 3.0', () => { + test('rejects malformed runtime verification metadata instead of coercing it', () => { + expect(() => + defineBookPolicy({ + ...customPolicy, + verification: { status: 'partial', notes: 'bad' } as never, + }), + ).toThrow('verification.notes must be an array'); + expect(() => + defineBookPolicy({ + ...customPolicy, + verification: { status: 'partial', notes: ['reviewed', ''] }, + }), + ).toThrow('verification.notes.1 must be a non-empty string'); + expect(() => + defineBookPolicy({ + ...customPolicy, + verification: { status: 'partial', reviewedAt: 17 } as never, + }), + ).toThrow('verification.reviewedAt must be a string'); + expect(() => + defineBookPolicy({ + ...customPolicy, + sources: [{ label: 'Fixture rules', retrievedAt: 17 } as never], + }), + ).toThrow('sources.0.retrievedAt must be a string'); + expect(() => + definePayoutTable({ + bookId: 'custom-book', + playTypeId: 'all-in', + effectiveFrom: '2026-05-01', + sources: [{ label: 'Fixture table', retrievedAt: new Date('2026-05-01') } as never], + entries: [{ pickCount: 2, hits: 2, multiplier: 3 }], + }), + ).toThrow('sources.0.retrievedAt must be a string'); + }); + + test('prefers outcome-specific payout rows regardless of table order', () => { + const generic = { pickCount: 1, hits: 1, multiplier: 2 }; + const tied = { pickCount: 1, hits: 1, pushes: 1, multiplier: 1.5 }; + + for (const entries of [ + [generic, tied], + [tied, generic], + ]) { + const engine = createDfsEngine({ + bookPolicies: [customPolicy], + payoutTables: [ + definePayoutTable({ + bookId: 'custom-book', + playTypeId: 'all-in', + effectiveFrom: '2026-05-01', + entries, + }), + ], + }); + expect( + engine.lookupPayout({ + bookId: 'custom-book', + playTypeId: 'all-in', + stake: 10, + pickCount: 1, + hits: 1, + pushes: 1, + removedCount: 1, + }), + ).toMatchObject({ status: 'won', multiplier: 1.5 }); + } + }); + test('settles a non-PrizePicks custom book with bookId/playTypeId and policy metadata', async () => { const engine = createDfsEngine({ bookPolicies: [customPolicy], @@ -154,6 +223,188 @@ describe('Book Policy Registry 3.0', () => { expect(result.explanationCodes).toContain('settlement.displayed_multiplier_payout'); }); + test('selects payout tables by an explicit UTC as-of and audits the selected table', async () => { + const datedPolicy = defineBookPolicy({ + ...customPolicy, + id: 'dated-book', + version: '2026-01', + effectiveFrom: '2026-01-01', + }); + const engine = createDfsEngine({ + clock: () => new Date('2026-06-15T12:00:00.000Z'), + bookPolicies: [datedPolicy], + payoutTables: [ + definePayoutTable({ + bookId: 'dated-book', + playTypeId: 'all-in', + version: '2026-05', + effectiveFrom: '2026-05-01', + sourceNotes: ['May fixture schedule'], + entries: [{ pickCount: 2, hits: 2, multiplier: 2 }], + }), + definePayoutTable({ + bookId: 'dated-book', + playTypeId: 'all-in', + version: '2026-07', + effectiveFrom: '2026-07-01', + sourceNotes: ['July fixture schedule'], + entries: [{ pickCount: 2, hits: 2, multiplier: 4 }], + }), + ], + }); + const lookup = (placedAt: string) => { + const placedEntry = entry({ bookId: 'dated-book', placedAt }); + return engine.lookupPayout({ + bookId: 'dated-book', + playTypeId: 'all-in', + stake: 10, + pickCount: 2, + hits: 2, + entry: placedEntry, + }); + }; + + expect(lookup('2026-06-15T12:00:00.000Z')).toMatchObject({ multiplier: 2 }); + expect(lookup('2026-07-15T12:00:00.000Z')).toMatchObject({ multiplier: 4 }); + expect(lookup('2026-04-30T23:59:59.000Z')).toBeNull(); + + const clockSelected = engine.lookupPayout({ + bookId: 'dated-book', + playTypeId: 'all-in', + stake: 10, + pickCount: 2, + hits: 2, + }); + expect(clockSelected).toMatchObject({ + multiplier: 2, + payoutTable: { + version: '2026-05', + effectiveFrom: '2026-05-01', + sourceNotes: ['May fixture schedule'], + sources: datedPolicy.sources, + }, + }); + + const settlementSelected = await engine.settleEntry(entry({ bookId: 'dated-book' }), { + settledAt: '2026-06-20T12:00:00.000Z', + actualsByLegId: { 'leg-1': 12, 'leg-2': 8 }, + }); + expect(settlementSelected).toMatchObject({ + effectiveMultiplier: 2, + payoutTable: { + version: '2026-05', + effectiveFrom: '2026-05-01', + sourceNotes: ['May fixture schedule'], + sources: datedPolicy.sources, + }, + }); + expect(settlementSelected.auditTrail.at(-1)?.metadata).toMatchObject({ + payoutTable: settlementSelected.payoutTable, + }); + + // Date-only effectiveFrom values are interpreted at UTC midnight. + await expect( + engine.settleEntry(entry({ bookId: 'dated-book' }), { + settledAt: '2026-06-30T23:59:59.999Z', + actualsByLegId: { 'leg-1': 12, 'leg-2': 8 }, + }), + ).resolves.toMatchObject({ + effectiveMultiplier: 2, + payoutTable: { version: '2026-05' }, + }); + await expect( + engine.settleEntry(entry({ bookId: 'dated-book' }), { + settledAt: '2026-07-01T00:00:00.000Z', + actualsByLegId: { 'leg-1': 12, 'leg-2': 8 }, + }), + ).resolves.toMatchObject({ + effectiveMultiplier: 4, + payoutTable: { version: '2026-07' }, + }); + + await expect( + engine.settleEntry(entry({ bookId: 'dated-book' }), { + settledAt: '2026-04-30T23:59:59.999Z', + actualsByLegId: { 'leg-1': 12, 'leg-2': 8 }, + }), + ).resolves.toMatchObject({ + status: 'pending', + payout: { total: 0, withdrawable: 0, bonus: 0 }, + payoutTable: null, + explanationCodes: expect.arrayContaining(['settlement.no_payout_resolution']), + }); + }); + + test('scales a demoted fixed-table payout against the original all-hit tier', () => { + const scalingPolicy = defineBookPolicy({ + ...customPolicy, + id: 'scaling-book', + payoutSplit: { type: 'underdog_bonus_split' }, + playTypes: [ + { + id: 'all-in', + displayName: 'All-In', + payoutModel: 'fixed-table', + pickCount: { min: 2, max: 3 }, + scaleDisplayedMultiplier: true, + }, + ], + }); + const engine = createDfsEngine({ + bookPolicies: [scalingPolicy], + payoutTables: [ + definePayoutTable({ + bookId: 'scaling-book', + playTypeId: 'all-in', + effectiveFrom: '2026-05-01', + entries: [ + { pickCount: 2, hits: 2, multiplier: 3 }, + { pickCount: 3, hits: 3, multiplier: 6 }, + ], + }), + ], + }); + const originalEntry = entry({ + bookId: 'scaling-book', + displayedMultiplier: 6, + placedAt: '2026-06-01', + legs: [leg({ legId: 'a' }), leg({ legId: 'b' }), leg({ legId: 'c' })], + }); + + expect( + engine.lookupPayout({ + bookId: 'scaling-book', + playTypeId: 'all-in', + stake: 10, + displayedMultiplier: 6, + pickCount: 2, + hits: 2, + removedCount: 1, + entry: originalEntry, + }), + ).toMatchObject({ + status: 'won', + multiplier: 3, + payout: { total: 30, withdrawable: 30, bonus: 0 }, + }); + expect( + engine.lookupPayout({ + bookId: 'scaling-book', + playTypeId: 'all-in', + stake: 10, + displayedMultiplier: 7, + pickCount: 2, + hits: 2, + removedCount: 1, + entry: originalEntry, + }), + ).toMatchObject({ + status: 'won', + multiplier: 3.5, + payout: { total: 35, withdrawable: 30, bonus: 5 }, + }); + }); + test('supports custom payout resolvers', async () => { const resolverPolicy = defineBookPolicy({ ...customPolicy, diff --git a/packages/dfs-engine/tests/engine-release-coverage.test.ts b/packages/dfs-engine/tests/engine-release-coverage.test.ts index 7200f81..e215e62 100644 --- a/packages/dfs-engine/tests/engine-release-coverage.test.ts +++ b/packages/dfs-engine/tests/engine-release-coverage.test.ts @@ -129,6 +129,14 @@ describe('engine release branch guardrails', () => { entries: [], }), ).toThrow('entries are required'); + expect(() => + definePayoutTable({ + bookId: 'guard-book', + playTypeId: 'main', + effectiveFrom: '2026-05-01', + entries: [{ pickCount: 1, hits: 1, pushes: -1, multiplier: 1.5 }], + }), + ).toThrow('pushes must be a non-negative integer'); expect(() => defineLeagueAdapter({ league: '' })).toThrow('league is required'); expect(() => defineStatProvider({ @@ -533,7 +541,7 @@ describe('engine release branch guardrails', () => { hits: 1, }), ).toMatchObject({ - status: 'lost', + status: 'pending', confidence: 'low', explanationCode: 'settlement.no_payout_table_row', }); diff --git a/packages/dfs-engine/tests/grade-bet.test.ts b/packages/dfs-engine/tests/grade-bet.test.ts index b812098..4a71700 100644 --- a/packages/dfs-engine/tests/grade-bet.test.ts +++ b/packages/dfs-engine/tests/grade-bet.test.ts @@ -122,9 +122,9 @@ describe('gradeDfsBetFromGraded — PrizePicks Flex', () => { profitBoostPct: null, }); expect(result.status).toBe('won'); - // Scaled: 25 × (1.75 / 25) = 1.75 - expect(result.effectiveMultiplier).toBe(1.75); - expect(result.totalPayout).toBe(17.5); + // Scaled: 25 × (2 / 25) = 2 + expect(result.effectiveMultiplier).toBe(2); + expect(result.totalPayout).toBe(20); }); test('3-of-6 hit → lost (below the schedule floor)', () => { diff --git a/packages/dfs-engine/tests/payouts.test.ts b/packages/dfs-engine/tests/payouts.test.ts index c9d5584..c2117c7 100644 --- a/packages/dfs-engine/tests/payouts.test.ts +++ b/packages/dfs-engine/tests/payouts.test.ts @@ -1,7 +1,7 @@ /** * Tests for DFS payout schedules + DNP demotion math. * - * Schedules are reference values current as of 2026-05; if either app + * Schedules are latest reference values reviewed on 2026-07-16; if either app * publishes new payouts these tests will fail loudly so we know to update * dfs-payouts.ts in lockstep. */ @@ -24,12 +24,27 @@ describe('dfs-payouts: lookupStandardMultiplier', () => { ).toBe(25); expect( lookupStandardMultiplier({ app: 'prizepicks', playType: 'flex', pickCount: 6, hits: 5 }), - ).toBe(1.75); + ).toBe(2); expect( lookupStandardMultiplier({ app: 'prizepicks', playType: 'flex', pickCount: 6, hits: 4 }), ).toBe(0.4); }); + test('PrizePicks latest standard Player Pick tiers match the July 2026 reference', () => { + expect( + lookupStandardMultiplier({ app: 'prizepicks', playType: 'power', pickCount: 3, hits: 3 }), + ).toBe(6); + expect( + lookupStandardMultiplier({ app: 'prizepicks', playType: 'flex', pickCount: 3, hits: 3 }), + ).toBe(3); + expect( + lookupStandardMultiplier({ app: 'prizepicks', playType: 'flex', pickCount: 3, hits: 2 }), + ).toBe(1); + expect( + lookupStandardMultiplier({ app: 'prizepicks', playType: 'flex', pickCount: 4, hits: 4 }), + ).toBe(6); + }); + test('Underdog Standard 4-pick all-hit = 10x', () => { expect( lookupStandardMultiplier({ @@ -92,7 +107,7 @@ describe('dfs-payouts: recalcMultiplierAfterDnp', () => { test('Flex demotion uses surviving hit count', () => { // 5-pick flex → 4-pick flex at 4/4 hits. - // PP 5-pick all-hit = 10x; PP 4-pick all-hit (flex) = 5x → ratio 0.5. + // PP 5-pick all-hit = 10x; PP 4-pick all-hit (flex) = 6x → ratio 0.6. const result = recalcMultiplierAfterDnp({ app: 'prizepicks', playType: 'flex', @@ -102,7 +117,7 @@ describe('dfs-payouts: recalcMultiplierAfterDnp', () => { originalMultiplier: 10, }); expect(result.usedFallback).toBe(false); - expect(result.newMultiplier).toBeCloseTo(5, 4); + expect(result.newMultiplier).toBeCloseTo(6, 4); }); test('throws when survivingPickCount is impossible', () => { diff --git a/packages/dfs-engine/tests/policy-truthfulness.test.ts b/packages/dfs-engine/tests/policy-truthfulness.test.ts new file mode 100644 index 0000000..e81c3bf --- /dev/null +++ b/packages/dfs-engine/tests/policy-truthfulness.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from 'vitest'; +import { + createDfsEngine, + type DfsBookPolicySnapshot, + type DfsEntryInput, + type DfsLegInput, +} from '../src'; + +const PRIZEPICKS_PAYOUTS_URL = 'https://www.prizepicks.com/help-center/payouts'; +const PRIZEPICKS_OUTCOMES_URL = 'https://www.prizepicks.com/help-center/potential-outcomes'; +const PRIZEPICKS_DNP_URL = 'https://www.prizepicks.com/help-center/dnps-reboots-and-ties'; +const UNDERDOG_LEGAL_URL = 'https://legal.underdogsports.com/'; + +function leg(index: number): DfsLegInput { + return { + legId: `leg-${index}`, + playerName: `Player ${index}`, + league: 'NBA', + propType: 'Points', + line: 20.5, + direction: 'over', + }; +} + +function entry(input: { + bookId: string; + playTypeId: string; + pickCount: number; + displayedMultiplier: number; + placedAt: string; +}): DfsEntryInput { + return { + entryId: `${input.bookId}-${input.playTypeId}-${input.placedAt}`, + bookId: input.bookId, + playTypeId: input.playTypeId, + stake: 10, + displayedMultiplier: input.displayedMultiplier, + placedAt: input.placedAt, + legs: Array.from({ length: input.pickCount }, (_, index) => leg(index + 1)), + }; +} + +function lookup(input: { + playTypeId: 'power' | 'flex'; + pickCount: number; + hits: number; + displayedMultiplier: number; + placedAt: string; +}) { + const engine = createDfsEngine(); + const placedEntry = entry({ + bookId: 'prizepicks', + playTypeId: input.playTypeId, + pickCount: input.pickCount, + displayedMultiplier: input.displayedMultiplier, + placedAt: input.placedAt, + }); + return engine.lookupPayout({ + bookId: 'prizepicks', + playTypeId: input.playTypeId, + stake: 10, + displayedMultiplier: input.displayedMultiplier, + pickCount: input.pickCount, + hits: input.hits, + losses: input.pickCount - input.hits, + entry: placedEntry, + }); +} + +describe('built-in policy truthfulness', () => { + test('preserves the May PrizePicks compatibility tables for historical entries', () => { + expect( + lookup({ + playTypeId: 'power', + pickCount: 3, + hits: 3, + displayedMultiplier: 5, + placedAt: '2026-07-01T23:59:59.999Z', + }), + ).toMatchObject({ multiplier: 5, payoutTable: { version: '2026-05', sources: [] } }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 3, + hits: 3, + displayedMultiplier: 2.25, + placedAt: '2026-06-01', + }), + ).toMatchObject({ multiplier: 2.25 }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 3, + hits: 2, + displayedMultiplier: 2.25, + placedAt: '2026-06-01', + }), + ).toMatchObject({ multiplier: 1.25 }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 4, + hits: 4, + displayedMultiplier: 5, + placedAt: '2026-06-01', + }), + ).toMatchObject({ multiplier: 5 }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 6, + hits: 5, + displayedMultiplier: 25, + placedAt: '2026-06-01', + }), + ).toMatchObject({ multiplier: 1.75 }); + }); + + test('uses the current standard Player Pick tables beginning July 2, 2026', () => { + expect( + lookup({ + playTypeId: 'power', + pickCount: 3, + hits: 3, + displayedMultiplier: 6, + placedAt: '2026-07-02T00:00:00.000Z', + }), + ).toMatchObject({ + multiplier: 6, + payoutTable: { + version: '2026-07-02-player-picks', + effectiveFrom: '2026-07-02', + sourceNotes: expect.arrayContaining([ + expect.stringContaining(PRIZEPICKS_PAYOUTS_URL), + expect.stringContaining(PRIZEPICKS_OUTCOMES_URL), + ]), + sources: expect.arrayContaining([ + expect.objectContaining({ url: PRIZEPICKS_PAYOUTS_URL, retrievedAt: '2026-07-16' }), + expect.objectContaining({ url: PRIZEPICKS_OUTCOMES_URL, retrievedAt: '2026-07-16' }), + ]), + }, + }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 3, + hits: 3, + displayedMultiplier: 3, + placedAt: '2026-07-16', + }), + ).toMatchObject({ multiplier: 3 }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 3, + hits: 2, + displayedMultiplier: 3, + placedAt: '2026-07-16', + }), + ).toMatchObject({ multiplier: 1 }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 4, + hits: 4, + displayedMultiplier: 6, + placedAt: '2026-07-16', + }), + ).toMatchObject({ multiplier: 6 }); + expect( + lookup({ + playTypeId: 'flex', + pickCount: 6, + hits: 5, + displayedMultiplier: 25, + placedAt: '2026-07-16', + }), + ).toMatchObject({ multiplier: 2 }); + }); + + test('returns deeply immutable authoritative policy snapshots', () => { + const engine = createDfsEngine(); + const policies = engine.getBookPolicies(); + const prizePicks = policies.find((policy) => policy.id === 'prizepicks'); + const underdog = policies.find((policy) => policy.id === 'underdog'); + + expect(policies.map((policy) => policy.id)).toEqual(['prizepicks', 'underdog']); + expect(prizePicks).toMatchObject({ + status: 'experimental', + verification: { status: 'partial', reviewedAt: '2026-07-16' }, + sources: expect.arrayContaining([ + expect.objectContaining({ url: PRIZEPICKS_PAYOUTS_URL, retrievedAt: '2026-07-16' }), + expect.objectContaining({ url: PRIZEPICKS_OUTCOMES_URL, retrievedAt: '2026-07-16' }), + expect.objectContaining({ url: PRIZEPICKS_DNP_URL, retrievedAt: '2026-07-16' }), + ]), + }); + expect(underdog).toMatchObject({ + status: 'experimental', + verification: { status: 'unverified', reviewedAt: '2026-07-16' }, + sources: [expect.objectContaining({ url: UNDERDOG_LEGAL_URL, retrievedAt: '2026-07-16' })], + }); + expect(Object.isFrozen(policies)).toBe(true); + expect(Object.isFrozen(prizePicks)).toBe(true); + expect(Object.isFrozen(prizePicks?.sources)).toBe(true); + expect(Object.isFrozen(prizePicks?.sources[0])).toBe(true); + expect(Object.isFrozen(prizePicks?.playTypes[0]?.pickCount)).toBe(true); + expect(Object.isFrozen(prizePicks?.verification)).toBe(true); + expect(Object.isFrozen(prizePicks?.verification?.notes)).toBe(true); + + expect(() => { + (prizePicks as DfsBookPolicySnapshot & { status: string }).status = 'stable'; + }).toThrow(TypeError); + expect(() => { + (prizePicks?.verification?.notes as string[]).push('mutated'); + }).toThrow(TypeError); + expect(engine.getBookPolicies().find((policy) => policy.id === 'prizepicks')).toMatchObject({ + status: 'experimental', + verification: { status: 'partial' }, + }); + }); + + test('propagates policy verification and caps settlement confidence', async () => { + const engine = createDfsEngine(); + const prizePicksEntry = entry({ + bookId: 'prizepicks', + playTypeId: 'power', + pickCount: 3, + displayedMultiplier: 6, + placedAt: '2026-07-16', + }); + const prizePicks = await engine.settleEntry(prizePicksEntry, { + actualsByLegId: Object.fromEntries(prizePicksEntry.legs.map((item) => [item.legId, 30])), + }); + + expect(prizePicks).toMatchObject({ + status: 'won', + effectiveMultiplier: 6, + policyStatus: 'experimental', + policyVerification: { status: 'partial', reviewedAt: '2026-07-16' }, + confidence: 'medium', + explanationCodes: expect.arrayContaining([ + 'policy.status.experimental', + 'policy.verification.partial', + ]), + }); + + const underdogEntry = entry({ + bookId: 'underdog', + playTypeId: 'underdog_standard', + pickCount: 2, + displayedMultiplier: 3, + placedAt: '2026-07-16', + }); + const underdog = await engine.settleEntry(underdogEntry, { + actualsByLegId: Object.fromEntries(underdogEntry.legs.map((item) => [item.legId, 30])), + }); + + expect(underdog).toMatchObject({ + status: 'won', + policyStatus: 'experimental', + policyVerification: { status: 'unverified', reviewedAt: '2026-07-16' }, + confidence: 'low', + sourceRefs: [expect.objectContaining({ url: UNDERDOG_LEGAL_URL })], + explanationCodes: expect.arrayContaining([ + 'policy.status.experimental', + 'policy.verification.unverified', + ]), + }); + }); +}); diff --git a/packages/dfs-engine/tests/policy-validator.test.ts b/packages/dfs-engine/tests/policy-validator.test.ts index 4507309..c93348e 100644 --- a/packages/dfs-engine/tests/policy-validator.test.ts +++ b/packages/dfs-engine/tests/policy-validator.test.ts @@ -54,6 +54,41 @@ describe('v5 validateBookPolicyDefinition', () => { expect(codes(result)).toContain('policy.invalid_status'); }); + test('validates optional policy verification metadata', () => { + expect( + codes( + validateBookPolicyDefinition({ + ...validPolicy(), + verification: { status: 'trusted', reviewedAt: '2026-07-16' }, + }), + ), + ).toContain('policy.invalid_verification_status'); + expect( + codes( + validateBookPolicyDefinition({ + ...validPolicy(), + verification: { status: 'partial', reviewedAt: 'not-a-date' }, + }), + ), + ).toContain('policy.invalid_verification_reviewed_at'); + expect( + codes( + validateBookPolicyDefinition({ + ...validPolicy(), + verification: { status: 'partial', notes: 'not-an-array' }, + }), + ), + ).toContain('policy.invalid_verification_notes'); + expect( + codes( + validateBookPolicyDefinition({ + ...validPolicy(), + verification: { status: 'partial', notes: ['reviewed', ' ', 42] }, + }), + ), + ).toContain('policy.invalid_verification_note'); + }); + test('rejects empty play type lists', () => { const result = validateBookPolicyDefinition({ ...validPolicy(), diff --git a/packages/dfs-engine/tests/pregame-dnp.test.ts b/packages/dfs-engine/tests/pregame-dnp.test.ts index 0633487..a2a9d2a 100644 --- a/packages/dfs-engine/tests/pregame-dnp.test.ts +++ b/packages/dfs-engine/tests/pregame-dnp.test.ts @@ -210,7 +210,7 @@ describe('applyLegDnp — Phase E.pre transitions', () => { }); describe('post-game-style DNP (surviving hits known) — recalc math sanity', () => { - test('PrizePicks Flex 4-pick @ 5x → DNP one leg with 3 surviving wins → 2.25x', () => { + test('PrizePicks Flex 4-pick @ 5x → DNP one leg with 3 surviving wins → 2.5x', () => { // Exercises the meaningful demotion path: ratio = surviving(3 picks, // 3 hits) / original(4 picks, 4 hits) = 2.25 / 5 = 0.45. // Slip multiplier 5 × 0.45 = 2.25. The pre-game path doesn't hit @@ -232,8 +232,8 @@ describe('applyLegDnp — Phase E.pre transitions', () => { }); expect(result.isVoided).toBe(false); - expect(result.newMultiplier).toBeCloseTo(2.25, 4); - expect(result.newPotentialPayout).toBeCloseTo(22.5, 2); + expect(result.newMultiplier).toBeCloseTo(2.5, 4); + expect(result.newPotentialPayout).toBeCloseTo(25, 2); }); }); diff --git a/packages/dfs-engine/tests/release-guardrails.test.ts b/packages/dfs-engine/tests/release-guardrails.test.ts index 3be023b..568db27 100644 --- a/packages/dfs-engine/tests/release-guardrails.test.ts +++ b/packages/dfs-engine/tests/release-guardrails.test.ts @@ -13,6 +13,10 @@ function readPackageJson(path: string): { return JSON.parse(readFileSync(resolve(root, path), 'utf8')); } +function readText(path: string): string { + return readFileSync(resolve(root, path), 'utf8'); +} + describe('release guardrails', () => { test('keeps release-hardening scripts wired at the workspace root', () => { const rootPackage = readPackageJson('package.json'); @@ -23,7 +27,37 @@ describe('release guardrails', () => { 'smoke:exports': 'node scripts/smoke-exports.mjs', 'size:check': 'node scripts/check-package-size.mjs', 'audit:high': 'npm audit --audit-level=high', + 'test:mcp:packed': expect.stringContaining('scripts/test-mcp-packed.mjs'), + 'proof:mcp:published': 'node scripts/prove-mcp-published.mjs', }); + expect(rootPackage.scripts?.verify).toContain('test:mcp:packed'); + + const mcpPackage = readPackageJson('packages/mcp/package.json'); + expect(mcpPackage.scripts?.prepack).toBe('npm run build'); + }); + + test('runs the packed MCP proof in CI on Linux, macOS, and Windows', () => { + const workflow = readText('.github/workflows/ci.yml'); + + expect(workflow).toContain('npm run test:mcp:packed'); + expect(workflow).toContain('macos-latest'); + expect(workflow).toContain('windows-latest'); + }); + + test('keeps a version-guarded post-publish proof workflow', () => { + const workflow = readText('.github/workflows/prove-mcp-published.yml'); + + expect(workflow).toContain('EXPECTED_MCP_VERSION'); + expect(workflow).toContain('npm run proof:mcp:published'); + }); + + test('proves an immutable published MCP artifact without forwarding parent secrets', () => { + const proof = readText('scripts/prove-mcp-published.mjs'); + + expect(proof).not.toContain('...process.env'); + expect(proof).toContain('@buzzr/mcp@${expectedVersion}'); + expect(proof).toContain('dist.integrity'); + expect(proof).toMatch(/assert\.match\(\s*expectedVersion/); }); test('keeps runtime dependencies intentionally tiny', () => { diff --git a/packages/dfs-engine/tests/semver-compatibility.test.ts b/packages/dfs-engine/tests/semver-compatibility.test.ts new file mode 100644 index 0000000..dcf30d6 --- /dev/null +++ b/packages/dfs-engine/tests/semver-compatibility.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; +import { createDfsEngine, defineBookPolicy } from '../src'; + +describe('minor-version compatibility metadata', () => { + test('keeps optional policy metadata present on engine-produced results', async () => { + const result = await createDfsEngine().settleEntry({ + entryId: 'runtime-metadata', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [], + }); + + expect(Object.hasOwn(result, 'policyStatus')).toBe(true); + expect(Object.hasOwn(result, 'policyVerification')).toBe(true); + expect(Object.hasOwn(result, 'payoutTable')).toBe(true); + }); + + test('preserves v5 shallow-freeze behavior for defineBookPolicy nested values', () => { + const defined = defineBookPolicy({ + id: 'compat-book', + displayName: 'Compatibility Book', + version: '1', + effectiveFrom: '2026-01-01', + status: 'draft', + sources: [{ label: 'Mutable nested source' }], + playTypes: [ + { + id: 'power', + displayName: 'Power', + payoutModel: 'displayed-multiplier', + pickCount: { min: 2, max: 4 }, + }, + ], + tiePolicy: { type: 'push' }, + dnpPolicy: { type: 'remove_leg', voidIfNoSurvivors: true }, + pushPolicy: { type: 'remove_leg', refundIfNoSurvivors: true }, + payoutSplit: { type: 'all_withdrawable' }, + }); + + expect(() => { + defined.sources[0].note = 'still mutable in the legacy definition API'; + defined.playTypes[0].pickCount.min = 1; + }).not.toThrow(); + expect(defined.sources[0].note).toBe('still mutable in the legacy definition API'); + expect(defined.playTypes[0].pickCount.min).toBe(1); + }); +}); diff --git a/packages/dfs-engine/tests/semver-compatibility.type-test.ts b/packages/dfs-engine/tests/semver-compatibility.type-test.ts new file mode 100644 index 0000000..6a59fb0 --- /dev/null +++ b/packages/dfs-engine/tests/semver-compatibility.type-test.ts @@ -0,0 +1,91 @@ +import { + createDfsEngine, + type DfsEngine, + type DfsEngineWithPolicySnapshots, + type DfsEntryInput, + type DfsSettlementResult, +} from '../src'; + +const legacyEntry: DfsEntryInput = { + entryId: 'legacy-entry', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [], +}; + +// A v5.0 consumer must be able to keep constructing the original settlement +// shape without supplying metadata introduced by a backwards-compatible minor. +const legacySettlementResult: DfsSettlementResult = { + entryId: legacyEntry.entryId, + bookId: legacyEntry.bookId, + playTypeId: legacyEntry.playTypeId, + status: 'pending', + multiplier: 0, + effectiveMultiplier: 0, + payout: { total: 0, withdrawable: 0, bonus: 0 }, + stake: legacyEntry.stake, + displayedMultiplier: legacyEntry.displayedMultiplier, + legs: [], + adjustments: [], + pendingReasons: [], + policyVersion: null, + sourceRefs: [], + confidence: 'low', + explanationCodes: [], + validation: { ok: true, value: legacyEntry, errors: [], warnings: [] }, + provenance: { providers: [], settledAt: '2026-07-16T00:00:00.000Z' }, + auditTrail: [], +}; + +// A v5.0 external engine implementation must remain assignable without +// implementing policy-snapshot discovery added by a minor release. +const legacyExternalEngine: DfsEngine = { + normalizeEntry(input) { + return input; + }, + extractLegStat() { + throw new Error('not implemented in compatibility fixture'); + }, + gradeLeg() { + throw new Error('not implemented in compatibility fixture'); + }, + lookupPayout() { + throw new Error('not implemented in compatibility fixture'); + }, + validateEntry() { + throw new Error('not implemented in compatibility fixture'); + }, + settleEntry() { + throw new Error('not implemented in compatibility fixture'); + }, + settleEntries() { + throw new Error('not implemented in compatibility fixture'); + }, + explainSettlement() { + throw new Error('not implemented in compatibility fixture'); + }, + registerBookPolicy() { + throw new Error('not implemented in compatibility fixture'); + }, + registerPayoutTable() { + throw new Error('not implemented in compatibility fixture'); + }, + registerLeagueAdapter() { + throw new Error('not implemented in compatibility fixture'); + }, + registerStatProvider() { + throw new Error('not implemented in compatibility fixture'); + }, + getRegisteredBooks() { + return []; + }, +}; + +const currentEngine: DfsEngineWithPolicySnapshots = createDfsEngine(); +const currentPolicySnapshots = currentEngine.getBookPolicies(); + +void legacySettlementResult; +void legacyExternalEngine; +void currentPolicySnapshots; diff --git a/packages/dfs-engine/tests/settlement-os.test.ts b/packages/dfs-engine/tests/settlement-os.test.ts index b213aa2..de20f1a 100644 --- a/packages/dfs-engine/tests/settlement-os.test.ts +++ b/packages/dfs-engine/tests/settlement-os.test.ts @@ -102,6 +102,53 @@ describe('v2 Settlement OS engine', () => { }); }); + test('selects a provider game-log row by the leg game date instead of array order', async () => { + const provider = defineStatProvider({ + id: 'dated-gamelog', + getGameLog: () => [ + gameLogEntry({ date: '2026-05-08T00:00:00.000Z', points: '12' }), + gameLogEntry({ date: '2026-05-07T00:00:00.000Z', points: '31' }), + ], + }); + const engine = createDfsEngine({ statProviders: [provider] }); + + await expect( + engine.extractLegStat(leg(), { statProviderId: 'dated-gamelog' }, entry({ legs: [leg()] })), + ).resolves.toMatchObject({ ok: true, value: 31 }); + }); + + test('leaves an ambiguous provider game log pending instead of guessing a row', async () => { + const provider = defineStatProvider({ + id: 'ambiguous-gamelog', + getGameLog: () => [ + gameLogEntry({ date: '2026-05-07T01:00:00.000Z', points: '31' }), + gameLogEntry({ date: '2026-05-07T05:00:00.000Z', points: '41' }), + ], + }); + const engine = createDfsEngine({ statProviders: [provider] }); + + await expect( + engine.extractLegStat( + leg(), + { statProviderId: 'ambiguous-gamelog' }, + entry({ legs: [leg()] }), + ), + ).resolves.toMatchObject({ ok: false, reason: 'missing_provider_data' }); + }); + + test('distinguishes a supported prop with missing data from an unsupported prop', async () => { + const engine = createDfsEngine(); + + await expect( + engine.extractLegStat(leg(), { actualEntry: gameLogEntry({ points: '' }) }), + ).resolves.toMatchObject({ ok: false, reason: 'missing_stat' }); + await expect( + engine.extractLegStat(leg({ propType: 'Unsupported Stat' }), { + actualEntry: gameLogEntry(), + }), + ).resolves.toMatchObject({ ok: false, reason: 'unsupported_prop' }); + }); + test('keeps league registries isolated per engine instance', async () => { const customLeague = defineLeagueAdapter({ league: 'SIM', @@ -166,6 +213,91 @@ describe('v2 Settlement OS engine', () => { expect(result.adjustments).toContainEqual(expect.objectContaining({ type: 'void' })); }); + test('marks an all-push refund as pushed while all-DNP remains void', async () => { + const engine = createDfsEngine(); + const result = await engine.settleEntry(entry({ stake: 25 }), { + actualsByLegId: { 'leg-1': 20.5, 'leg-2': 7.5 }, + }); + + expect(result.status).toBe('pushed'); + expect(result.payout).toEqual({ total: 25, withdrawable: 25, bonus: 0 }); + expect(result.explanationCodes).toContain('refund_no_survivors'); + expect(result.auditTrail.at(-1)?.code).toBe('settlement.pushed'); + }); + + test('cent-rounds payout totals and split components', async () => { + const roundedPolicy = { + id: 'rounding-book', + displayName: 'Rounding Book', + version: 'test-1', + effectiveFrom: '2026-01-01', + status: 'draft' as const, + sources: [{ label: 'Rounding fixture' }], + playTypes: [ + { + id: 'main', + displayName: 'Main', + payoutModel: 'displayed-multiplier' as const, + pickCount: { min: 2, max: 2 }, + }, + ], + tiePolicy: { type: 'push' as const }, + dnpPolicy: { type: 'remove_leg' as const, voidIfNoSurvivors: true }, + pushPolicy: { type: 'remove_leg' as const, refundIfNoSurvivors: true }, + payoutSplit: { + type: 'custom' as const, + split: ({ totalPayout }: { totalPayout: number }) => ({ + total: totalPayout, + withdrawable: totalPayout * (2 / 3), + bonus: totalPayout * (1 / 3), + }), + }, + }; + const engine = createDfsEngine({ bookPolicies: [roundedPolicy] }); + + const result = await engine.settleEntry( + entry({ + bookId: 'rounding-book', + playTypeId: 'main', + stake: 10.01, + displayedMultiplier: 1.005, + }), + { actualsByLegId: { 'leg-1': 30, 'leg-2': 10 } }, + ); + + expect(result.payout).toEqual({ total: 10.06, withdrawable: 6.71, bonus: 3.35 }); + + const halfCent = await engine.settleEntry( + entry({ + entryId: 'half-cent', + bookId: 'rounding-book', + playTypeId: 'main', + stake: 1, + displayedMultiplier: 1.005, + }), + { actualsByLegId: { 'leg-1': 30, 'leg-2': 10 } }, + ); + expect(halfCent.payout).toEqual({ total: 1.01, withdrawable: 0.67, bonus: 0.34 }); + + for (const [stake, expectedTotal] of [ + [10.075, 10.08], + [128.015, 128.02], + ] as const) { + const decimalHalf = await engine.settleEntry( + entry({ + entryId: `decimal-half-${stake}`, + bookId: 'rounding-book', + playTypeId: 'main', + stake, + displayedMultiplier: 1, + }), + { actualsByLegId: { 'leg-1': 30, 'leg-2': 10 } }, + ); + + expect(decimalHalf.payout.total).toBe(expectedTotal); + } + }); + test('prices boosted Underdog flex payouts against the surviving hit count', async () => { const engine = createDfsEngine(); const result = await engine.settleEntry( diff --git a/packages/dfs-engine/tests/settlement-status-regression.test.ts b/packages/dfs-engine/tests/settlement-status-regression.test.ts new file mode 100644 index 0000000..f2f00a4 --- /dev/null +++ b/packages/dfs-engine/tests/settlement-status-regression.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'vitest'; + +import { createDfsEngine, type DfsEntryInput, type DfsLegInput } from '../src'; + +function leg(overrides: Partial = {}): DfsLegInput { + return { + legId: 'leg-1', + playerName: 'Regression Guard', + league: 'NBA', + propType: 'points', + line: 20.5, + direction: 'over', + ...overrides, + }; +} + +function entry(overrides: Partial = {}): DfsEntryInput { + return { + entryId: 'entry-1', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + placedAt: '2026-07-16T00:00:00.000Z', + legs: [leg(), leg({ legId: 'leg-2' })], + ...overrides, + }; +} + +describe('authoritative leg status regressions', () => { + test('refunds a two-pick PrizePicks Power entry after one DNP', async () => { + const result = await createDfsEngine().settleEntry( + entry({ + legs: [leg({ status: 'dnp' }), leg({ legId: 'leg-2', status: 'won' })], + }), + ); + + expect(result).toMatchObject({ + status: 'void', + multiplier: 1, + effectiveMultiplier: 1, + payout: { total: 10, withdrawable: 10, bonus: 0 }, + legs: [ + { legId: 'leg-1', status: 'dnp' }, + { legId: 'leg-2', status: 'won' }, + ], + }); + expect(result.explanationCodes).toContain('refund_below_minimum'); + expect(result.auditTrail.at(-1)?.code).toBe('settlement.void'); + }); + + test('settles explicit won and lost statuses without requiring stat data', async () => { + const engine = createDfsEngine(); + + const won = await engine.settleEntry( + entry({ + legs: [leg({ status: 'won' }), leg({ legId: 'leg-2', status: 'won' })], + }), + ); + expect(won).toMatchObject({ + status: 'won', + multiplier: 3, + legs: [ + { status: 'won', actual: null, provider: { source: 'status' } }, + { status: 'won', actual: null, provider: { source: 'status' } }, + ], + }); + + const lost = await engine.settleEntry(entry(), { + legStatusesByLegId: { 'leg-1': 'won', 'leg-2': 'lost' }, + }); + expect(lost).toMatchObject({ + status: 'lost', + payout: { total: 0, withdrawable: 0, bonus: 0 }, + legs: [{ status: 'won' }, { status: 'lost' }], + }); + }); + + test('settles the current two-pick PrizePicks Power tie outcomes', async () => { + const engine = createDfsEngine(); + + const wonWithTie = await engine.settleEntry(entry(), { + legStatusesByLegId: { 'leg-1': 'won', 'leg-2': 'push' }, + }); + expect(wonWithTie).toMatchObject({ + status: 'won', + multiplier: 1.5, + effectiveMultiplier: 1.5, + payout: { total: 15, withdrawable: 15, bonus: 0 }, + legs: [{ status: 'won' }, { status: 'push' }], + payoutTable: { version: '2026-07-02-player-picks' }, + }); + + const lostWithTie = await engine.settleEntry(entry(), { + legStatusesByLegId: { 'leg-1': 'lost', 'leg-2': 'push' }, + }); + expect(lostWithTie).toMatchObject({ + status: 'lost', + multiplier: 0, + payout: { total: 0, withdrawable: 0, bonus: 0 }, + legs: [{ status: 'lost' }, { status: 'push' }], + }); + }); + + test('never turns an unpriced zero-loss outcome into a financial loss', async () => { + const engine = createDfsEngine(); + const prizePicks = await engine.settleEntry( + entry({ + entryId: 'unpriced-prizepicks', + displayedMultiplier: 6, + legs: [leg(), leg({ legId: 'leg-2' }), leg({ legId: 'leg-3' })], + }), + { + legStatusesByLegId: { 'leg-1': 'won', 'leg-2': 'push', 'leg-3': 'push' }, + }, + ); + expect(prizePicks).toMatchObject({ + status: 'pending', + multiplier: 0, + payout: { total: 0, withdrawable: 0, bonus: 0 }, + pendingReasons: ['missing_payout_table_row'], + explanationCodes: expect.arrayContaining(['settlement.no_payout_table_row']), + }); + + const underdog = await engine.settleEntry( + entry({ + entryId: 'unpriced-underdog', + bookId: 'underdog', + playTypeId: 'underdog_standard', + }), + { legStatusesByLegId: { 'leg-1': 'won', 'leg-2': 'push' } }, + ); + expect(underdog).toMatchObject({ + status: 'pending', + payout: { total: 0, withdrawable: 0, bonus: 0 }, + pendingReasons: ['missing_payout_table_row'], + }); + }); +}); diff --git a/packages/dfs-engine/tsconfig.type-tests.json b/packages/dfs-engine/tsconfig.type-tests.json new file mode 100644 index 0000000..58bad39 --- /dev/null +++ b/packages/dfs-engine/tsconfig.type-tests.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.type-test.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/dfs-engine/typedoc.json b/packages/dfs-engine/typedoc.json deleted file mode 100644 index 0f563bd..0000000 --- a/packages/dfs-engine/typedoc.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://typedoc.org/schema.json", - "entryPoints": ["src/index.ts"], - "out": "docs", - "name": "@buzzr/dfs-engine", - "readme": "README.md", - "excludePrivate": true, - "excludeInternal": true, - "categorizeByGroup": true, - "navigation": { - "includeCategories": true, - "includeGroups": true - }, - "githubPages": false, - "hideGenerator": true -} diff --git a/packages/dfs-provider-espn/CHANGELOG.md b/packages/dfs-provider-espn/CHANGELOG.md index 4aeba6f..2b95ca0 100644 --- a/packages/dfs-provider-espn/CHANGELOG.md +++ b/packages/dfs-provider-espn/CHANGELOG.md @@ -1,5 +1,12 @@ # @buzzr/dfs-provider-espn +## 5.0.0 + +### Major Changes + +- Synchronized the ESPN-shaped stat-provider adapter with + `@buzzr/dfs-engine@^5.0.0`; its public loader and provider APIs were unchanged. + ## 4.0.0 ### Major Changes diff --git a/packages/dfs-provider-espn/README.md b/packages/dfs-provider-espn/README.md index 555b204..6079534 100644 --- a/packages/dfs-provider-espn/README.md +++ b/packages/dfs-provider-espn/README.md @@ -55,10 +55,22 @@ The engine validates every returned row at the provider boundary and reports `in ## Links -- [Engine API docs](https://buzzr-app.github.io/dfs-engine/) +- [All-package API index](../../docs/api-reference.md) +- [Generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-provider-espn.html) - [Monorepo & full package family](https://github.com/Buzzr-app/dfs-engine) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/dfs-provider-espn`. +Deep `src/*` and `dist/*` imports are unsupported. This package adapts a +consumer-owned loader; it does not include an ESPN client, credentials, or +network transport. + +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/dfs-provider-sportradar/CHANGELOG.md b/packages/dfs-provider-sportradar/CHANGELOG.md index cbc327f..976634a 100644 --- a/packages/dfs-provider-sportradar/CHANGELOG.md +++ b/packages/dfs-provider-sportradar/CHANGELOG.md @@ -1,5 +1,12 @@ # @buzzr/dfs-provider-sportradar +## 5.0.0 + +### Major Changes + +- Synchronized the Sportradar adapter with `@buzzr/dfs-engine@^5.0.0`; its + row-conversion and provider APIs were unchanged. + ## 1.0.0 ### Major Changes diff --git a/packages/dfs-provider-sportradar/README.md b/packages/dfs-provider-sportradar/README.md index 78c4ce5..ac39fa4 100644 --- a/packages/dfs-provider-sportradar/README.md +++ b/packages/dfs-provider-sportradar/README.md @@ -57,10 +57,22 @@ Rows are converted with `sportradarRowToGameLog` before they reach the engine, a ## Links -- [Engine API docs](https://buzzr-app.github.io/dfs-engine/) +- [All-package API index](../../docs/api-reference.md) +- [Generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-provider-sportradar.html) - [Monorepo & full package family](https://github.com/Buzzr-app/dfs-engine) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/dfs-provider-sportradar`. +Deep `src/*` and `dist/*` imports are unsupported. This package adapts a +consumer-owned loader; it does not include a Sportradar client, credentials, or +network transport. + +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/dfs-react/CHANGELOG.md b/packages/dfs-react/CHANGELOG.md index 608172d..482670f 100644 --- a/packages/dfs-react/CHANGELOG.md +++ b/packages/dfs-react/CHANGELOG.md @@ -1,5 +1,12 @@ # @buzzr/dfs-react +## 5.0.0 + +### Major Changes + +- Synchronized the framework-agnostic display helpers with + `@buzzr/dfs-engine@^5.0.0`; the exported display-model API was unchanged. + ## 1.0.0 ### Major Changes diff --git a/packages/dfs-react/README.md b/packages/dfs-react/README.md index 81e3e0c..9f19c3b 100644 --- a/packages/dfs-react/README.md +++ b/packages/dfs-react/README.md @@ -73,10 +73,22 @@ Each leg comes back as `"Jayson Tatum — Points"` (`label`) with an `"o26.5"` / ## Links -- [Engine API docs](https://buzzr-app.github.io/dfs-engine/) +- [All-package API index](../../docs/api-reference.md) +- [Generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-react.html) - [Monorepo & full package family](https://github.com/Buzzr-app/dfs-engine) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/dfs-react`. +Deep `src/*` and `dist/*` imports are unsupported. This package has no React +runtime dependency, but it does depend on the compatible `@buzzr/dfs-engine` +range declared in its package manifest. + +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/dfs-testkit/CHANGELOG.md b/packages/dfs-testkit/CHANGELOG.md index a37468a..a399a21 100644 --- a/packages/dfs-testkit/CHANGELOG.md +++ b/packages/dfs-testkit/CHANGELOG.md @@ -1,5 +1,20 @@ # @buzzr/dfs-testkit +## 5.0.1 + +### Patch Changes + +- 5be07fa: Clarify that the versioned engine fixtures are regression evidence rather than official operator conformance. +- Updated dependencies [5be07fa] + - @buzzr/dfs-engine@5.1.0 + +## 5.0.0 + +### Major Changes + +- Synchronized the fixture builders and mock stat provider with + `@buzzr/dfs-engine@^5.0.0` while retaining the v4-canonical fixture shapes. + ## 4.0.0 ### Major Changes diff --git a/packages/dfs-testkit/README.md b/packages/dfs-testkit/README.md index f80eff1..660cbcb 100644 --- a/packages/dfs-testkit/README.md +++ b/packages/dfs-testkit/README.md @@ -67,16 +67,30 @@ Fixtures emit v4-canonical shapes (`actual`, `status`, per-leg `legId` keys), so | You want to… | Reach for | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Write unit tests for your own settlement integration | **this package** | -| Prove your wiring grades identically to Buzzr's | [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) | +| Replay versioned engine regression fixtures | [`@buzzr/dfs-engine-test-vectors`](https://www.npmjs.com/package/@buzzr/dfs-engine-test-vectors) | | Grade entries in production code | [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine) | | Grade entries from the command line | [`@buzzr/dfs-cli`](https://www.npmjs.com/package/@buzzr/dfs-cli) | +The regression fixtures are review gates for a matching engine version, not official operator conformance. + ## Links -- [Engine API docs](https://buzzr-app.github.io/dfs-engine/) +- [All-package API index](../../docs/api-reference.md) +- [Generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_dfs-testkit.html) - [Monorepo & full package family](https://github.com/Buzzr-app/dfs-engine) - [Issues](https://github.com/Buzzr-app/dfs-engine/issues) +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/dfs-testkit`. +Deep `src/*` and `dist/*` imports are unsupported. The package is intended for +development and test dependencies and uses the compatible `@buzzr/dfs-engine` +range declared in its package manifest. + +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/dfs-testkit/package.json b/packages/dfs-testkit/package.json index 72dd296..74d5512 100644 --- a/packages/dfs-testkit/package.json +++ b/packages/dfs-testkit/package.json @@ -1,6 +1,6 @@ { "name": "@buzzr/dfs-testkit", - "version": "5.0.0", + "version": "5.0.1", "description": "Fixture builders and mock providers for @buzzr/dfs-engine consumers.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -55,7 +55,7 @@ "buzzr" ], "dependencies": { - "@buzzr/dfs-engine": "^5.0.0" + "@buzzr/dfs-engine": "^5.1.0" }, "engines": { "node": ">=22" diff --git a/packages/entertainment-engine/README.md b/packages/entertainment-engine/README.md index 9869cc6..d66b547 100644 --- a/packages/entertainment-engine/README.md +++ b/packages/entertainment-engine/README.md @@ -8,6 +8,12 @@ Supabase, AsyncStorage, or app services — and it has zero runtime dependencies. Apps and jobs provide data through plain objects, then persist results however they choose. +## Install + +```bash +npm install @buzzr/entertainment-engine +``` + ## Core API ```ts @@ -140,3 +146,19 @@ adjustment, and individual signed factor deltas. - Training helpers accept already-built examples; Supabase extraction belongs in the consuming app or job. - `ENGINE_PACKAGE_VERSION` reports the package version (`5.0.0`). + +## Compatibility and support + +Node.js >= 22 is supported. Import the supported API from `@buzzr/entertainment-engine`. +Deep `src/*` and `dist/*` imports are unsupported. See the +[all-package API index](../../docs/api-reference.md) and the +[generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_entertainment-engine.html). + +Report reproducible defects in [GitHub Issues](https://github.com/Buzzr-app/dfs-engine/issues). +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + +## License + +[MIT](../../LICENSE) diff --git a/packages/entertainment-engine/typedoc.json b/packages/entertainment-engine/typedoc.json new file mode 100644 index 0000000..5ca2a26 --- /dev/null +++ b/packages/entertainment-engine/typedoc.json @@ -0,0 +1,3 @@ +{ + "intentionallyNotExported": ["ModelRunReportInput"] +} diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md new file mode 100644 index 0000000..502b80d --- /dev/null +++ b/packages/mcp/CHANGELOG.md @@ -0,0 +1,42 @@ +# @buzzr/mcp + +## 5.1.0 + +### Minor Changes + +- 5be07fa: Expand to eleven bounded tools with batch DFS settlement, closing-line value, bet-history analytics, authoritative policy discovery, portable bins, published examples, real-client proofs, and hardened packed/published startup and validation. + +### Patch Changes + +- Updated dependencies [5be07fa] + - @buzzr/dfs-engine@5.1.0 + +### Added + +- Expanded the catalog to 11 tools with batch DFS settlement, closing-line + value, and bounded bet-history summaries. +- Added authoritative policy snapshots with executable flags, verification + metadata, source references, and complete play-type definitions. + +### Security and correctness + +- Bounded input frames, payloads, identifiers, arrays, concurrency, error detail, + and serialized results; public runtime errors no longer expose internals. +- Kept draft policy fixtures metadata-only and non-executable. +- Unified real-client and direct-handler tool validation behind bounded + `invalid_input` results so SDK validation cannot amplify adversarial errors. +- Oversized stdio frames now close the server and terminate with an error even + when a misbehaving client keeps its stdin pipe open. + +### Documentation + +- Labeled PrizePicks experimental/partial and Underdog + experimental/unverified, with displayed entry terms authoritative. + +## 5.0.0 + +### Major Changes + +- Published the first synchronized Buzzr MCP server with eight DFS settlement, + odds, Kelly, parlay, prediction, and ranking tools over local stdio transport. +- Pinned the v5 Buzzr engine family and exposed the `buzzr-mcp` executable. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index beb2c13..2ff115e 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -9,7 +9,7 @@ server that puts the whole @buzzr engine family in front of any MCP-capable agen in the underlying engines — this package is a thin, schema-validated tool surface: - [`@buzzr/dfs-engine`](https://www.npmjs.com/package/@buzzr/dfs-engine) — pick-em - settlement with real book policies (PrizePicks, Underdog, drafts) + settlement with versioned compatibility and custom policies - [`@buzzr/bets-core`](https://www.npmjs.com/package/@buzzr/bets-core) — no-vig fair lines, parlay pricing, expected value, Kelly staking - [`@buzzr/entertainment-engine`](https://www.npmjs.com/package/@buzzr/entertainment-engine) — @@ -17,24 +17,28 @@ in the underlying engines — this package is a thin, schema-validated tool surf ## Install -Run it directly with npx (Node 22+): +Run it directly with npx (Node 22+). Pin the version you reviewed instead of +silently accepting a future `latest` release: ```sh -npx -y @buzzr/mcp +npx -y @buzzr/mcp@5.1.0 ``` The server speaks MCP over stdio: JSON-RPC on stdin/stdout, logs on stderr. +It exposes 11 tools with bounded inputs and outputs, and does not fetch live +odds, box scores, operator accounts, or private user data. ### Claude Desktop -Add to `claude_desktop_config.json` (Settings → Developer → Edit Config): +Add this to `claude_desktop_config.json` from **Settings → Developer → Edit +Config**, then fully quit and reopen Claude Desktop: ```json { "mcpServers": { "buzzr": { "command": "npx", - "args": ["-y", "@buzzr/mcp"] + "args": ["-y", "@buzzr/mcp@5.1.0"] } } } @@ -43,39 +47,177 @@ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config): ### Claude Code ```sh -claude mcp add buzzr -- npx -y @buzzr/mcp +claude mcp add --transport stdio buzzr -- npx -y @buzzr/mcp@5.1.0 ``` -or in `.mcp.json`: +For a version-controlled project configuration, add this to `.mcp.json`, trust +the project when prompted, and start a new Claude Code session: ```json { "mcpServers": { "buzzr": { "command": "npx", - "args": ["-y", "@buzzr/mcp"] + "args": ["-y", "@buzzr/mcp@5.1.0"] } } } ``` +Confirm it with `claude mcp get buzzr`; inside Claude Code, `/mcp` shows the +connection and discovered tool count. + +### Cursor + +Add this to the repository's `.cursor/mcp.json` (or the equivalent user-level +MCP configuration), then fully restart Cursor: + +```json +{ + "mcpServers": { + "buzzr": { + "command": "npx", + "args": ["-y", "@buzzr/mcp@5.1.0"] + } + } +} +``` + +Open **Cursor Settings → MCP** to confirm `buzzr` connected and exposed 11 +tools. See [Cursor's MCP documentation](https://cursor.com/docs/context/mcp) if +your installed Cursor version presents a different settings location. + +### Codex + +The Codex CLI, IDE extension, and app share `config.toml`. The one-command user +setup is: + +```sh +codex mcp add buzzr -- npx -y @buzzr/mcp@5.1.0 +``` + +Or add the equivalent block to `~/.codex/config.toml` for all projects, or to a +trusted repository's `.codex/config.toml` for that project only: + +```toml +[mcp_servers.buzzr] +command = "npx" +args = ["-y", "@buzzr/mcp@5.1.0"] +``` + +Run `codex mcp get buzzr`, then start a new Codex task after changing the +configuration. + +### Generic stdio MCP client + +Use this process configuration in any client that accepts a command plus args: + +```json +{ + "name": "buzzr", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@buzzr/mcp@5.1.0"], + "env": {} +} +``` + +Treat stdout as JSON-RPC only, read diagnostics from stderr, and close the child +process's stdin during shutdown. The client must perform the MCP lifecycle below; +starting `npx` in a terminal and seeing it remain open is normal for a stdio +server waiting for a client. + +## Initialization and capability discovery + +The client sends `initialize` first. Verify the `serverInfo.name` field is +`"buzzr"`, the `serverInfo.version` field is the installed package version, and +the response has a `capabilities.tools` object. Send +`notifications/initialized`, then call `tools/list`. The server returns the 11 +tools in the catalog below with their input schemas. It does not advertise data +fetching, resources, or prompts. + +[`examples/mcp-calls.json`](examples/mcp-calls.json) is a machine-readable JSON-RPC +transcript with the initialization expectation, exact tool discovery order, and +a safe `list_book_policies` → `validate_dfs_entry` → `grade_dfs_entry` workflow. +The repository replays that workflow through a real MCP client in CI. + +## Startup troubleshooting + +1. Confirm Node.js 22+ and npm are visible to the same desktop process or shell: + `node --version`, `npm --version`, and `npx --version`. +2. Confirm the pinned release exists with + `npm view @buzzr/mcp@5.1.0 version`, then run + `npm cache verify`. If npm reports cache corruption, repair npm's cache before + retrying; `npm cache clean --force` is a last resort because it removes the + whole local cache. +3. GUI apps may inherit a smaller `PATH` than an interactive shell. On macOS or + Linux, run `command -v npx`; on Windows, run `where.exe npx`. If the client + cannot find `npx`, use that absolute path as `command` (normally `npx.cmd` on + Windows) or launch the client from an environment where Node is on `PATH`. +4. Keep logs off stdout. A healthy manual start writes a `listening on stdio` + diagnostic to stderr and waits for JSON-RPC input. +5. After any config, Node, PATH, or package-version change, fully quit and restart + Claude Desktop, Claude Code, Cursor, or Codex. Opening only another chat tab + may leave the old MCP child process and cached tool catalog in place. + ## Tool catalog -| Tool | Engine | What it does | -| -------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `grade_dfs_entry` | @buzzr/dfs-engine | Settle a DFS pick-em entry: applies the book policy (ties, DNPs, flex tables) and returns status, payout split, per-leg decisions, and explanation codes. | -| `validate_dfs_entry` | @buzzr/dfs-engine | Run the engine's runtime validators against a candidate entry; returns structured error/warning issues without settling. | -| `list_book_policies` | @buzzr/dfs-engine | Enumerate the registered DFS books (built-in stable policies plus draft fixtures) with play types and policy status. | -| `fair_line` | @buzzr/bets-core | Remove the vig from a two-sided market: fair probability, fair American odds, overround, and edge vs. the offered price. | -| `parlay_value` | @buzzr/bets-core | Price a parlay: per-leg no-vig probabilities, fair combined odds, edge of the offered price, optional expected value. | -| `kelly_stake` | @buzzr/bets-core | Kelly-criterion stake sizing with fractional-Kelly support (defaults to quarter-Kelly). | -| `predict_game_buzz` | @buzzr/entertainment-engine | Predict a game's 1–10 entertainment (buzz) score with model confidence and weighted factor breakdown. | -| `rank_games` | @buzzr/entertainment-engine | Rank candidate games for a user's taste profile: base score plus bounded personal-affinity and social adjustments. | - -Every tool validates its input with zod before touching an engine, and returns -results as JSON text content. Failures come back as structured MCP error results -(`isError: true` with `{ "error": { "code", "message" } }`) instead of protocol -errors — agents can read and recover from them. +| Tool | Engine | What it does | +| ----------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `grade_dfs_entry` | @buzzr/dfs-engine | Settle one transport-bounded 1–12-leg entry; executable built-in policies currently allow at most 6 PrizePicks or 8 Underdog legs. | +| `grade_dfs_entries` | @buzzr/dfs-engine | Settle 1–50 entries, up to 600 total legs, with bounded concurrency and isolated failures. | +| `validate_dfs_entry` | @buzzr/dfs-engine | Return structured engine validation issues for a candidate entry without settling it. | +| `list_book_policies` | @buzzr/dfs-engine | List authoritative executable profile snapshots and metadata-only drafts, including status, verification, sources, and complete play types. | +| `fair_line` | @buzzr/bets-core | Remove vig from both sides of one two-way market. | +| `closing_line_value` | @buzzr/bets-core | Compare placed and closing prices for the same selection. | +| `parlay_value` | @buzzr/bets-core | Price independent parlay legs, compare offered odds, and optionally calculate expected value. | +| `kelly_stake` | @buzzr/bets-core | Calculate full and fractional Kelly stakes from a supplied win probability. | +| `summarize_bet_history` | @buzzr/bets-core | Summarize up to 500 bets with overall and UTC-period rollups, drawdown, and streaks. | +| `predict_game_buzz` | @buzzr/entertainment-engine | Predict one game's 1–10 entertainment score with confidence and factor detail. | +| `rank_games` | @buzzr/entertainment-engine | Rank 1–100 candidate games for a bounded taste profile. | + +Tool strings, identifiers, arrays, stdio frames, concurrent calls, and serialized +results are bounded. American odds must be within `[-100000, -100]` or +`[100, 100000]`. `grade_dfs_entries`, `closing_line_value`, and +`summarize_bet_history` return string `contractVersion: "1"`. + +## DFS policy safety + +Operator-named policies are independent compatibility profiles, not official +rules engines or evidence of affiliation: + +- PrizePicks is experimental and partially verified. Standard payout references + were reviewed on 2026-07-16 from + [PrizePicks Payouts](https://www.prizepicks.com/help-center/payouts) and + [PrizePicks Potential Outcomes](https://www.prizepicks.com/help-center/potential-outcomes), + with DNP behavior checked against + [DNPs, Reboots, and Ties](https://www.prizepicks.com/help-center/dnps-reboots-and-ties). + The compatibility policy treats a 2-pick Power entry with a DNP as a refund + when it falls below the two-pick minimum. Other settlement behavior and + variable lineup-specific payouts remain incomplete. +- Underdog is experimental and unverified. The + [Underdog Sports Legal Center](https://legal.underdogsports.com/) is the recorded + rules entrypoint; the current compatibility payout and settlement values have + not been verified. + +Displayed lineup terms are authoritative. Call `list_book_policies` before +grading, inspect verification and sources, and obtain explicit operator rulings +for DNPs, reboots, ties, rescues, voids, and corrections. The tool lists future +fixtures with `executable: false`; grading tools reject draft book IDs because +drafts are metadata, not settlement implementations. + +## Error contracts + +- Invalid tool arguments return the same bounded `invalid_input` result through + an MCP client transport or a direct exported `tool.handler(...)` call. At most + eight compact validation issues are included; raw Zod errors are never returned. +- Malformed JSON-RPC envelopes remain protocol errors owned by the MCP SDK and + are distinct from a valid `tools/call` request with invalid tool arguments. +- `validate_dfs_entry` intentionally accepts a bounded candidate object and + returns the engine's structured validation report. +- Execution failures return generic `isError: true` results such as + `tool_execution_failed`, `entry_settlement_failed`, `server_busy`, or + `result_too_large`. Internal error details are not public. ## Example transcripts @@ -101,7 +243,9 @@ errors — agents can read and recover from them. > ``` > > Result: `"status": "won"`, `"payout": { "total": 30, "withdrawable": 30, "bonus": 0 }` — -> both legs won, the 2-pick power table pays 3x. +> both supplied actuals clear their lines and the submitted 3× displayed multiplier +> is consistent with the selected compatibility table. Confirm the actual entry +> details before treating this as an operator outcome. **"Is this parlay +EV?"** @@ -142,13 +286,38 @@ Individual tool definitions (`gradeDfsEntryTool`, `fairLineTool`, …) are expor too — each is `{ name, title, description, inputSchema, handler }`, and handlers can be called directly without any transport. +## Codex skill + +The repository includes a +[Buzzr Sports Engine skill](../../skills/buzzr-sports-engine/SKILL.md) with the +11-tool routing guide, limits, response-reading order, and operator-safety rules: + +```sh +npx skills@1.5.17 add https://github.com/Buzzr-app/dfs-engine --skill buzzr-sports-engine --agent codex --yes --copy +``` + +Repository contributors can prove the same one-command install without using a +published branch: `npx skills@1.5.17 add . --skill buzzr-sports-engine --agent +codex --yes --copy`. `npm run check:skill` runs the official pinned +`quick_validate.py`, performs that local install in an isolated temporary +project, and verifies every installed skill file byte-for-byte. + ## Compatibility -- Node.js >= 22 +- Node.js >= 22 is supported. - `rank_games` requires `@buzzr/entertainment-engine` >= 5.0.0. Against an older engine build the tool degrades gracefully with an `engine_capability_missing` error result instead of crashing the server. +Import the supported API from `@buzzr/mcp`. Deep `src/*` and `dist/*` imports are unsupported. +See the [all-package API index](../../docs/api-reference.md) and the +[generated root-export reference](https://buzzr-app.github.io/dfs-engine/modules/_buzzr_mcp.html). + +Report reproducible defects in [GitHub Issues](https://github.com/Buzzr-app/dfs-engine/issues). +Report vulnerabilities privately through [SECURITY.md](../../SECURITY.md). The +[versioning and support policy](../../docs/versioning-and-support.md) defines the +supported runtime and SemVer contract. + ## License -MIT +[MIT](../../LICENSE) diff --git a/packages/mcp/examples/mcp-calls.json b/packages/mcp/examples/mcp-calls.json new file mode 100644 index 0000000..1961b7a --- /dev/null +++ b/packages/mcp/examples/mcp-calls.json @@ -0,0 +1,174 @@ +{ + "schemaVersion": "1", + "transport": "stdio", + "framing": "newline-delimited JSON-RPC 2.0", + "discovery": { + "initialize": { + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "buzzr-machine-readable-example", + "version": "1.0.0" + } + } + }, + "expect": { + "serverInfo": { + "name": "buzzr", + "version": "$PACKAGE_VERSION" + }, + "capabilities": { + "tools": { + "listChanged": true + } + } + } + }, + "initialized": { + "jsonrpc": "2.0", + "method": "notifications/initialized" + }, + "toolsList": { + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }, + "expect": { + "toolNames": [ + "grade_dfs_entry", + "grade_dfs_entries", + "validate_dfs_entry", + "list_book_policies", + "fair_line", + "closing_line_value", + "parlay_value", + "kelly_stake", + "summarize_bet_history", + "predict_game_buzz", + "rank_games" + ] + } + } + }, + "workflows": [ + { + "id": "safe-single-entry-settlement", + "intent": "Inspect policy provenance, validate supplied entry data, then settle one entry.", + "calls": [ + { + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "list_book_policies", + "arguments": {} + } + }, + "expect": { + "count": 6, + "books.0.id": "prizepicks", + "books.0.status": "experimental", + "books.0.verification.status": "partial", + "books.0.executable": true + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "validate_dfs_entry", + "arguments": { + "entry": { + "entryId": "skill-example", + "bookId": "prizepicks", + "playTypeId": "power", + "stake": 10, + "displayedMultiplier": 3, + "legs": [ + { + "legId": "leg-1", + "playerName": "Player One", + "league": "NBA", + "propType": "points", + "line": 25.5, + "direction": "over", + "actual": 31 + }, + { + "legId": "leg-2", + "playerName": "Player Two", + "league": "NBA", + "propType": "points", + "line": 27.5, + "direction": "over", + "actual": 33 + } + ] + } + } + } + }, + "expect": { + "ok": true, + "errors": [], + "warnings": [] + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "grade_dfs_entry", + "arguments": { + "entryId": "skill-example", + "bookId": "prizepicks", + "playTypeId": "power", + "stake": 10, + "displayedMultiplier": 3, + "legs": [ + { + "legId": "leg-1", + "playerName": "Player One", + "league": "NBA", + "propType": "points", + "line": 25.5, + "direction": "over", + "actual": 31 + }, + { + "legId": "leg-2", + "playerName": "Player Two", + "league": "NBA", + "propType": "points", + "line": 27.5, + "direction": "over", + "actual": 33 + } + ] + } + } + }, + "expect": { + "entryId": "skill-example", + "status": "won", + "payout.total": 30, + "policyStatus": "experimental", + "policyVerification.status": "partial", + "validation.ok": true + } + } + ] + } + ] +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a40db88..a21adaf 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,7 @@ { "name": "@buzzr/mcp", - "version": "5.0.0", + "version": "5.1.0", + "mcpName": "io.github.Buzzr-app/dfs-engine", "description": "MCP server exposing the @buzzr sports engines to AI agents — DFS settlement, odds math, and entertainment predictions as tools.", "license": "MIT", "author": "Sarvesh Chidambaram", @@ -15,10 +16,12 @@ } }, "bin": { + "mcp": "./dist/cli.js", "buzzr-mcp": "./dist/cli.js" }, "files": [ "dist", + "examples", "README.md", "LICENSE" ], @@ -28,7 +31,8 @@ "test": "vitest run", "lint": "eslint src tests", "format": "prettier --write src tests", - "format:check": "prettier --check src tests" + "format:check": "prettier --check src tests", + "prepack": "npm run build" }, "keywords": [ "mcp", @@ -51,9 +55,9 @@ "url": "https://github.com/Buzzr-app/dfs-engine/issues" }, "dependencies": { - "@buzzr/bets-core": "^5.0.0", - "@buzzr/dfs-engine": "^5.0.0", - "@buzzr/entertainment-engine": "^5.0.0", + "@buzzr/bets-core": "5.0.0", + "@buzzr/dfs-engine": "5.1.0", + "@buzzr/entertainment-engine": "5.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index 2382363..137b172 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -1,18 +1,34 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createBuzzrMcpServer, SERVER_NAME, SERVER_VERSION } from './server'; +import { BoundedNewlineInput } from './stdin-frame-limiter'; + +const INPUT_REJECTED_MESSAGE = 'buzzr-mcp rejected oversized input.\n'; +const STARTUP_FAILED_MESSAGE = 'buzzr-mcp failed to start.\n'; async function main(): Promise { const server = createBuzzrMcpServer(); - const transport = new StdioServerTransport(); + const boundedInput = new BoundedNewlineInput(); + process.stdin.pipe(boundedInput); + boundedInput.once('error', () => { + process.stdin.unpipe(boundedInput); + process.stdin.pause(); + process.exitCode = 1; + void server + .close() + .catch(() => undefined) + .then(() => { + process.stderr.write(INPUT_REJECTED_MESSAGE, () => process.exit(1)); + }); + }); + + const transport = new StdioServerTransport(boundedInput); await server.connect(transport); // stdout is the MCP protocol channel; human-facing logs go to stderr. process.stderr.write(`${SERVER_NAME} MCP server v${SERVER_VERSION} listening on stdio\n`); } -main().catch((error: unknown) => { - process.stderr.write( - `buzzr-mcp failed to start: ${error instanceof Error ? error.message : String(error)}\n`, - ); +main().catch(() => { + process.stderr.write(STARTUP_FAILED_MESSAGE); process.exit(1); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 9bb5d09..abc0aa3 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,5 +1,5 @@ /** - * @buzzr/mcp — MCP server exposing the @buzzr sports engines to AI agents. + * The `@buzzr/mcp` server exposes the Buzzr sports engines to AI agents. * * Run `npx @buzzr/mcp` (bin: buzzr-mcp) for a stdio server, or import * `createBuzzrMcpServer` to embed the tool catalog in your own server. @@ -15,11 +15,19 @@ export { export { dfsTools, + gradeDfsEntriesTool, gradeDfsEntryTool, listBookPoliciesTool, validateDfsEntryTool, } from './tools/dfs'; -export { fairLineTool, kellyStakeTool, oddsTools, parlayValueTool } from './tools/odds'; +export { historyTools, summarizeBetHistoryTool } from './tools/history'; +export { + closingLineValueTool, + fairLineTool, + kellyStakeTool, + oddsTools, + parlayValueTool, +} from './tools/odds'; export { buzzTools, createPredictGameBuzzTool, diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index de1e64b..763de49 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,24 +1,51 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import packageManifest from '../package.json' with { type: 'json' }; import { buzzTools } from './tools/buzz'; import { dfsTools } from './tools/dfs'; +import { historyTools } from './tools/history'; import { oddsTools } from './tools/odds'; import type { BuzzrToolDefinition, ToolResult } from './tools/shared'; export const SERVER_NAME = 'buzzr'; -export const SERVER_VERSION = '5.0.0'; +export const SERVER_VERSION = packageManifest.version; /** Every tool this server ships, in catalog order. */ -export const allTools: readonly BuzzrToolDefinition[] = [...dfsTools, ...oddsTools, ...buzzTools]; +export const allTools: readonly BuzzrToolDefinition[] = [ + ...dfsTools, + ...oddsTools, + ...historyTools, + ...buzzTools, +]; + +function transportInputSchema(tool: BuzzrToolDefinition) { + const schema = z.toJSONSchema(tool.inputSchema, { + target: 'draft-7', + // boundedArray uses a size-only input stage and a fully described output + // stage; advertise the latter while the handler still parses both. + io: 'output', + }); + if (schema.type !== 'object') { + throw new TypeError(`Tool ${tool.name} must expose an object input schema.`); + } + const { $schema: _schemaDialect, ...discoveryMetadata } = schema; + return z.object({}).passthrough().meta(discoveryMetadata); +} -/** Registers one @buzzr tool definition on an McpServer instance. */ +/** + * Registers one Buzzr tool through the SDK's native registry. The transport + * schema accepts an argument object while advertising the full discovery + * schema; the handler then owns bounded validation and error serialization. + */ export function registerBuzzrTool(server: McpServer, tool: BuzzrToolDefinition): void { server.registerTool( tool.name, { title: tool.title, description: tool.description, - inputSchema: tool.inputSchema, + inputSchema: transportInputSchema(tool), }, (args: unknown): Promise => tool.handler(args), ); diff --git a/packages/mcp/src/stdin-frame-limiter.ts b/packages/mcp/src/stdin-frame-limiter.ts new file mode 100644 index 0000000..607b367 --- /dev/null +++ b/packages/mcp/src/stdin-frame-limiter.ts @@ -0,0 +1,94 @@ +import { Transform } from 'node:stream'; +import type { TransformCallback } from 'node:stream'; + +const CARRIAGE_RETURN = 0x0d; +const NEWLINE = 0x0a; +const NEWLINE_BUFFER = Buffer.from('\n'); + +export const MAX_STDIN_FRAME_BYTES = 2 * 1_024 * 1_024; + +export class StdinFrameLimitError extends Error { + override readonly name = 'StdinFrameLimitError'; + + constructor() { + super('Input frame exceeded the configured limit.'); + } +} + +/** + * Holds chunk slices until a newline arrives, then emits one complete frame. + * This bounds the SDK's downstream ReadBuffer and avoids repeated concatenation + * while a frame is still arriving. + */ +export class BoundedNewlineInput extends Transform { + private chunks: Buffer[] = []; + private bufferedBytes = 0; + + override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void { + let offset = 0; + + while (offset < chunk.length) { + const newlineIndex = chunk.indexOf(NEWLINE, offset); + if (newlineIndex === -1) { + this.appendReference(chunk.subarray(offset)); + if (this.exceedsPendingLimit()) { + this.reject(callback); + return; + } + callback(); + return; + } + + this.appendReference(chunk.subarray(offset, newlineIndex)); + if (this.framePayloadBytes() > MAX_STDIN_FRAME_BYTES) { + this.reject(callback); + return; + } + + this.push(Buffer.concat([...this.chunks, NEWLINE_BUFFER], this.bufferedBytes + 1)); + this.reset(); + offset = newlineIndex + 1; + } + + callback(); + } + + override _flush(callback: TransformCallback): void { + // MCP stdio requires newline-delimited JSON. An incomplete EOF frame is + // intentionally discarded and never reaches the SDK's parser. + this.reset(); + callback(); + } + + private appendReference(chunk: Buffer): void { + if (chunk.length === 0) { + return; + } + this.chunks.push(chunk); + this.bufferedBytes += chunk.length; + } + + private exceedsPendingLimit(): boolean { + const allowance = this.endsWithCarriageReturn() ? 1 : 0; + return this.bufferedBytes > MAX_STDIN_FRAME_BYTES + allowance; + } + + private framePayloadBytes(): number { + return this.bufferedBytes - (this.endsWithCarriageReturn() ? 1 : 0); + } + + private endsWithCarriageReturn(): boolean { + const finalChunk = this.chunks.at(-1); + return finalChunk !== undefined && finalChunk.at(-1) === CARRIAGE_RETURN; + } + + private reject(callback: TransformCallback): void { + this.reset(); + callback(new StdinFrameLimitError()); + } + + private reset(): void { + this.chunks = []; + this.bufferedBytes = 0; + } +} diff --git a/packages/mcp/src/tools/buzz.ts b/packages/mcp/src/tools/buzz.ts index c876076..697a06e 100644 --- a/packages/mcp/src/tools/buzz.ts +++ b/packages/mcp/src/tools/buzz.ts @@ -2,6 +2,15 @@ import * as entertainmentEngine from '@buzzr/entertainment-engine'; import type { EntertainmentGameInput, PredictionContext } from '@buzzr/entertainment-engine'; import { z } from 'zod'; +import { + boundedArray, + boundedIdentifier, + boundedLabel, + boundedRecord, + finiteNumber, + isoTimestamp, + nonNegativeFiniteNumber, +} from './schemas'; import { defineTool, errorResult, jsonResult } from './shared'; import type { BuzzrToolDefinition } from './shared'; @@ -34,16 +43,16 @@ const statusSchema = z const oddsSchema = z .object({ - spread: z.number().nullish().describe('Point spread relative to the home team.'), - overUnder: z.number().nullish(), - homeMoneyline: z.number().nullish(), - awayMoneyline: z.number().nullish(), + spread: finiteNumber.nullish().describe('Point spread relative to the home team.'), + overUnder: finiteNumber.nullish(), + homeMoneyline: finiteNumber.nullish(), + awayMoneyline: finiteNumber.nullish(), }) .describe('Betting-market context for the game.'); const teamPairSchema = z.object({ - home: z.number().nullish(), - away: z.number().nullish(), + home: finiteNumber.nullish(), + away: finiteNumber.nullish(), }); const narrativesSchema = z @@ -56,27 +65,24 @@ const narrativesSchema = z .describe('Narrative flags that boost buzz (rivalries, debuts, revenge games).'); const engagementSchema = z.object({ - fireCount: z.number().nullish(), - skipCount: z.number().nullish(), - averageRating: z.number().nullish(), - ratingCount: z.number().nullish(), + fireCount: nonNegativeFiniteNumber.nullish(), + skipCount: nonNegativeFiniteNumber.nullish(), + averageRating: finiteNumber.nullish(), + ratingCount: nonNegativeFiniteNumber.nullish(), }); const searchHeatSchema = z .object({ - home: z.number().min(-1).max(1).nullish(), - away: z.number().min(-1).max(1).nullish(), + home: finiteNumber.min(-1).max(1).nullish(), + away: finiteNumber.min(-1).max(1).nullish(), }) .describe('Per-team search interest in [-1, 1].'); const gameFieldsSchema = z.object({ - league: z.string().min(1).describe('League code, e.g. "NBA", "NFL", "EPL", "WC".'), - homeTeam: z.string().min(1), - awayTeam: z.string().min(1), - startsAt: z - .string() - .min(1) - .describe('ISO 8601 kickoff/tip-off time, e.g. "2026-07-06T19:30:00Z".'), + league: boundedIdentifier.describe('League code, e.g. "NBA", "NFL", "EPL", "WC".'), + homeTeam: boundedLabel, + awayTeam: boundedLabel, + startsAt: isoTimestamp.describe('ISO 8601 kickoff/tip-off time, e.g. "2026-07-06T19:30:00Z".'), status: statusSchema.optional(), gameType: z .enum(['regular', 'playin', 'playoff']) @@ -90,6 +96,7 @@ const gameFieldsSchema = z.object({ searchHeat: searchHeatSchema.optional(), starPower: z .number() + .finite() .min(0) .max(1) .nullish() @@ -168,39 +175,41 @@ export function createPredictGameBuzzTool( const profileSchema = z .object({ - favoriteTeams: z.array(z.string()).optional(), - favoriteLeagues: z.array(z.string()).optional(), - teamAffinity: z - .record(z.string(), z.number().min(-1).max(1)) + favoriteTeams: boundedArray(boundedLabel, 50).optional(), + favoriteLeagues: boundedArray(boundedIdentifier, 50).optional(), + teamAffinity: boundedRecord(boundedLabel, finiteNumber.min(-1).max(1), 100, 'teamAffinity') .optional() .describe('Per-team affinity in [-1, 1], keyed by team name.'), - leagueAffinity: z - .record(z.string(), z.number().min(-1).max(1)) + leagueAffinity: boundedRecord( + boundedIdentifier, + finiteNumber.min(-1).max(1), + 100, + 'leagueAffinity', + ) .optional() .describe('Per-league affinity in [-1, 1], keyed by league code.'), - socialSignal: z - .record(z.string(), z.number().min(-1).max(1)) + socialSignal: boundedRecord(boundedIdentifier, finiteNumber.min(-1).max(1), 100, 'socialSignal') .optional() .describe('Fire-ratio in [-1, 1], keyed by game id.'), }) .describe('The user taste profile used to personalize the ranking.'); const rankGamesSchema = z.object({ - games: z - .array( - gameFieldsSchema.extend({ - id: z.string().nullish().describe('Stable game id, used for social signals.'), - baseScore: z - .number() - .min(0) - .max(10) - .nullish() - .describe('Precomputed base entertainment score (1-10). Estimated when absent.'), - }), - ) - .min(1), + games: boundedArray( + gameFieldsSchema.extend({ + id: boundedIdentifier.nullish().describe('Stable game id, used for social signals.'), + baseScore: z + .number() + .min(0) + .max(10) + .nullish() + .describe('Precomputed base entertainment score (1-10). Estimated when absent.'), + }), + 100, + 1, + ), profile: profileSchema.default({}), - limit: z.number().int().positive().optional().describe('Return only the top N games.'), + limit: z.number().int().positive().max(100).optional().describe('Return only the top N games.'), }); export function createRankGamesTool( diff --git a/packages/mcp/src/tools/dfs.ts b/packages/mcp/src/tools/dfs.ts index 01ba100..9faaf5c 100644 --- a/packages/mcp/src/tools/dfs.ts +++ b/packages/mcp/src/tools/dfs.ts @@ -4,6 +4,7 @@ import { validateDfsEntryInput, } from '@buzzr/dfs-engine'; import type { + DfsBatchEntryFailure, DfsBookPolicy, DfsEntryInput, DfsLegInput, @@ -11,11 +12,20 @@ import type { } from '@buzzr/dfs-engine'; import { z } from 'zod'; +import { + boundedIdentifier, + boundedArray, + boundedLabel, + boundedRecord, + finiteNumber, + isoDateOrTimestamp, + isoTimestamp, + nonNegativeFiniteNumber, + positiveFiniteNumber, +} from './schemas'; import { defineTool, jsonResult } from './shared'; import type { BuzzrToolDefinition } from './shared'; -const americanNumber = z.number(); - const legStatusSchema = z.enum([ 'pending', 'won', @@ -29,18 +39,20 @@ const legStatusSchema = z.enum([ ]); const legSchema = z.object({ - legId: z.string().min(1).describe('Stable id for this leg, unique within the entry.'), - playerId: z.string().nullish().describe('Optional provider player id.'), - playerName: z.string().min(1).describe('Player display name, e.g. "LeBron James".'), - team: z.string().nullish(), - opponent: z.string().nullish(), - gameId: z.string().nullish(), - gameDate: z.string().nullish().describe('ISO date of the game, e.g. "2026-07-06".'), - league: z.string().min(1).describe('League code, e.g. "NBA", "NFL", "MLB".'), - propType: z.string().min(1).describe('Prop market, e.g. "points", "rebounds", "pass_yards".'), - line: americanNumber.describe('The prop line, e.g. 25.5.'), + legId: boundedIdentifier.describe('Stable id for this leg, unique within the entry.'), + playerId: boundedIdentifier.nullish().describe('Optional provider player id.'), + playerName: boundedLabel.describe('Player display name, e.g. "LeBron James".'), + team: boundedLabel.nullish(), + opponent: boundedLabel.nullish(), + gameId: boundedIdentifier.nullish(), + gameDate: isoDateOrTimestamp + .nullish() + .describe('ISO date or timestamp for the game, e.g. "2026-07-06".'), + league: boundedIdentifier.describe('League code, e.g. "NBA", "NFL", "MLB".'), + propType: boundedIdentifier.describe('Prop market, e.g. "points", "rebounds", "pass_yards".'), + line: finiteNumber.describe('The prop line, e.g. 25.5.'), direction: z.enum(['over', 'under']).describe('Which side of the line was picked.'), - actual: americanNumber + actual: finiteNumber .nullish() .describe('Observed stat value, when already known. Omit to leave the leg pending.'), status: legStatusSchema @@ -48,30 +60,72 @@ const legSchema = z.object({ .describe('Pre-graded leg status (e.g. "dnp" or "void") when the book already ruled it.'), }); -const gradeDfsEntrySchema = z.object({ - entryId: z.string().min(1).describe('Stable id for the entry being graded.'), - bookId: z - .string() - .min(1) - .describe('DFS book id, e.g. "prizepicks" or "underdog". See list_book_policies.'), - playTypeId: z - .string() - .min(1) - .describe('Play type id for the book, e.g. "power", "flex", "underdog_standard".'), - stake: z.number().positive().describe('Entry stake in currency units.'), - displayedMultiplier: z - .number() - .positive() - .describe('The payout multiplier the book displayed at entry time.'), - baseMultiplier: z.number().positive().nullish(), - profitBoostPct: z.number().min(0).nullish(), - placedAt: z.string().nullish().describe('ISO timestamp the entry was placed.'), - legs: z.array(legSchema).min(1), - actualsByLegId: z - .record(z.string(), z.number().nullable()) - .optional() - .describe('Optional map of legId to observed stat value, merged in at settlement time.'), -}); +const entryFields = { + entryId: boundedIdentifier.describe('Stable id for the entry being graded.'), + bookId: boundedIdentifier.describe( + 'DFS book id, e.g. "prizepicks" or "underdog". See list_book_policies.', + ), + playTypeId: boundedIdentifier.describe( + 'Play type id for the book, e.g. "power", "flex", "underdog_standard".', + ), + stake: positiveFiniteNumber.describe('Entry stake in currency units.'), + displayedMultiplier: positiveFiniteNumber.describe( + 'The payout multiplier the book displayed at entry time.', + ), + baseMultiplier: positiveFiniteNumber.nullish(), + profitBoostPct: nonNegativeFiniteNumber.nullish(), + placedAt: isoTimestamp.nullish().describe('ISO timestamp the entry was placed.'), + legs: boundedArray(legSchema, 12, 1), +}; + +function requireUniqueLegIds( + value: { legs: readonly { legId: string }[] }, + context: z.RefinementCtx, +): void { + const seen = new Set(); + for (const [index, leg] of value.legs.entries()) { + if (seen.has(leg.legId)) { + context.addIssue({ + code: 'custom', + path: ['legs', index, 'legId'], + message: `Duplicate legId: ${leg.legId}`, + }); + } + seen.add(leg.legId); + } +} + +const actualsByLegIdSchema = boundedRecord( + boundedIdentifier, + finiteNumber.nullable(), + 12, + 'actualsByLegId', +); + +const gradeDfsEntrySchema = z + .object({ + ...entryFields, + actualsByLegId: actualsByLegIdSchema + .optional() + .describe('Optional map of legId to observed stat value, merged in at settlement time.'), + }) + .superRefine((value, context) => { + requireUniqueLegIds(value, context); + if (value.actualsByLegId) { + const legIds = new Set(value.legs.map((leg) => leg.legId)); + for (const legId of Object.keys(value.actualsByLegId)) { + if (!legIds.has(legId)) { + context.addIssue({ + code: 'custom', + path: ['actualsByLegId', legId], + message: `actualsByLegId contains unknown legId: ${legId}`, + }); + } + } + } + }); + +const batchEntrySchema = z.object(entryFields).superRefine(requireUniqueLegIds); type GradeDfsEntryArgs = z.output; @@ -107,9 +161,9 @@ function toDfsEntryInput(args: GradeDfsEntryArgs): DfsEntryInput { }; } -/** Engine with the built-in stable books plus the published draft fixtures. */ +/** Engine with only the built-in executable compatibility policies. */ function buildEngine() { - return createDfsEngine({ bookPolicies: DRAFT_BOOK_POLICY_FIXTURES }); + return createDfsEngine(); } export const gradeDfsEntryTool = defineTool({ @@ -126,28 +180,91 @@ export const gradeDfsEntryTool = defineTool({ ? { actualsByLegId: args.actualsByLegId } : undefined; const result = await engine.settleEntry(toDfsEntryInput(args), context); + return jsonResult({ ...result, explanation: engine.explainSettlement(result) }); + }, +}); + +const gradeDfsEntriesSchema = z + .object({ + entries: boundedArray(batchEntrySchema, 50, 1), + concurrency: z.number().int().min(1).max(8).optional(), + }) + .superRefine((value, context) => { + const entryIds = new Set(); + let totalLegs = 0; + for (const [index, entry] of value.entries.entries()) { + totalLegs += entry.legs.length; + if (entryIds.has(entry.entryId)) { + context.addIssue({ + code: 'custom', + path: ['entries', index, 'entryId'], + message: `Duplicate entryId: ${entry.entryId}`, + }); + } + entryIds.add(entry.entryId); + } + if (totalLegs > 600) { + context.addIssue({ + code: 'custom', + path: ['entries'], + message: 'A batch cannot contain more than 600 total legs.', + }); + } + }); + +export function serializeBatchFailure(failure: DfsBatchEntryFailure) { + return { + entryId: failure.entryId, + index: failure.index, + error: { + code: 'entry_settlement_failed', + message: 'Entry settlement failed.', + }, + }; +} + +export const gradeDfsEntriesTool = defineTool({ + name: 'grade_dfs_entries', + title: 'Grade DFS entries', + description: + 'Settle up to 50 DFS entries with @buzzr/dfs-engine batch settlement. ' + + 'Returns full explainable settlement results, isolated serializable failures, ' + + 'summary counts, and per-call stat-cache metrics.', + inputSchema: gradeDfsEntriesSchema, + run: async (args) => { + const engine = buildEngine(); + const batch = await engine.settleEntries(args.entries.map(toDfsEntryInput), { + concurrency: args.concurrency ?? 1, + }); return jsonResult({ - entryId: result.entryId, - bookId: result.bookId, - playTypeId: result.playTypeId, - status: result.status, - multiplier: result.multiplier, - effectiveMultiplier: result.effectiveMultiplier, - stake: result.stake, - payout: result.payout, - legs: result.legs, - adjustments: result.adjustments, - pendingReasons: result.pendingReasons, - explanationCodes: result.explanationCodes, - confidence: result.confidence, - policyVersion: result.policyVersion, + contractVersion: '1', + results: batch.results.map((result) => ({ + ...result, + explanation: engine.explainSettlement(result), + })), + failures: batch.failures.map(serializeBatchFailure), + summary: batch.summary, + cache: batch.cache, }); }, }); const validateDfsEntrySchema = z.object({ entry: z - .record(z.string(), z.unknown()) + .record(boundedIdentifier, z.unknown()) + .refine((entry) => Object.keys(entry).length <= 1_000, { + message: 'Entry objects cannot contain more than 1,000 top-level fields.', + }) + .refine( + (entry) => { + try { + return Buffer.byteLength(JSON.stringify(entry), 'utf8') <= 64 * 1_024; + } catch { + return false; + } + }, + { message: 'Entry payload cannot exceed 64 KiB of JSON.' }, + ) .describe( 'A candidate DfsEntryInput object (entryId, bookId, playTypeId, stake, ' + 'displayedMultiplier, legs[]). Passed as-is to the engine validators so ' + @@ -173,53 +290,18 @@ export const validateDfsEntryTool = defineTool({ }, }); -/** - * Play-type metadata for the engine's built-in stable policies. The engine's - * public API exposes registered book ids (getRegisteredBooks) but not the - * built-in policy objects themselves, so this mirrors the ids/display names of - * the built-ins shipped in @buzzr/dfs-engine v5. - */ -const BUILT_IN_POLICY_SUMMARIES: readonly { - id: string; - displayName: string; - status: string; - playTypes: { id: string; displayName: string }[]; -}[] = [ - { - id: 'prizepicks', - displayName: 'PrizePicks', - status: 'stable', - playTypes: [ - { id: 'power', displayName: 'Power Play' }, - { id: 'flex', displayName: 'Flex Play' }, - ], - }, - { - id: 'underdog', - displayName: 'Underdog', - status: 'stable', - playTypes: [ - { id: 'underdog_standard', displayName: 'Standard' }, - { id: 'underdog_flex', displayName: 'Flex' }, - ], - }, -]; - -function describePolicy(policy: DfsBookPolicy) { +function describeDraftPolicy(policy: DfsBookPolicy) { return { id: policy.id, displayName: policy.displayName, - status: policy.status, version: policy.version, + effectiveFrom: policy.effectiveFrom, + status: policy.status, + verification: policy.verification ?? null, + sources: policy.sources, + playTypes: policy.playTypes, + executable: false as const, source: 'draft_fixture' as const, - playTypes: policy.playTypes.map((playType) => ({ - id: playType.id, - displayName: playType.displayName, - payoutModel: playType.payoutModel, - pickCount: playType.pickCount, - allOrNothing: playType.allOrNothing ?? false, - flex: playType.flex ?? false, - })), }; } @@ -227,27 +309,19 @@ export const listBookPoliciesTool = defineTool({ name: 'list_book_policies', title: 'List DFS book policies', description: - 'Enumerate the DFS books the settlement engine knows: built-in stable policies ' + - '(PrizePicks, Underdog) plus the published draft fixtures, with play types and ' + - 'policy status. Use the ids here as bookId/playTypeId for grade_dfs_entry.', + 'Enumerate registered executable DFS compatibility policies from the engine and ' + + 'published draft fixtures that are metadata-only. Includes version, effective date, ' + + 'verification, source references, complete play-type metadata, and executable status.', inputSchema: z.object({}), run: () => { const engine = buildEngine(); - const registeredIds = engine.getRegisteredBooks(); - const draftsById = new Map(DRAFT_BOOK_POLICY_FIXTURES.map((policy) => [policy.id, policy])); - const builtInsById = new Map(BUILT_IN_POLICY_SUMMARIES.map((summary) => [summary.id, summary])); - - const books = registeredIds.map((id) => { - const draft = draftsById.get(id); - if (draft) { - return describePolicy(draft); - } - const builtIn = builtInsById.get(id); - if (builtIn) { - return { ...builtIn, source: 'built_in' as const }; - } - return { id, displayName: id, status: 'unknown', source: 'engine' as const, playTypes: [] }; - }); + const executableBooks = engine.getBookPolicies().map((policy) => ({ + ...policy, + executable: true as const, + source: 'built_in' as const, + })); + const draftBooks = DRAFT_BOOK_POLICY_FIXTURES.map(describeDraftPolicy); + const books = [...executableBooks, ...draftBooks]; return jsonResult({ count: books.length, books }); }, @@ -255,6 +329,7 @@ export const listBookPoliciesTool = defineTool({ export const dfsTools: readonly BuzzrToolDefinition[] = [ gradeDfsEntryTool, + gradeDfsEntriesTool, validateDfsEntryTool, listBookPoliciesTool, ]; diff --git a/packages/mcp/src/tools/history.ts b/packages/mcp/src/tools/history.ts new file mode 100644 index 0000000..7fec382 --- /dev/null +++ b/packages/mcp/src/tools/history.ts @@ -0,0 +1,95 @@ +import { + calculateBetRollup, + calculateDrawdown, + calculateRollupByPeriod, + calculateStreaks, +} from '@buzzr/bets-core'; +import { z } from 'zod'; + +import { + americanOdds, + boundedArray, + boundedIdentifier, + boundedLabel, + finiteNumber, + isoTimestamp, + nonNegativeFiniteNumber, +} from './schemas'; +import { defineTool, jsonResult } from './shared'; +import type { BuzzrToolDefinition } from './shared'; + +const betStatusSchema = z.enum([ + 'draft', + 'pending', + 'won', + 'lost', + 'pushed', + 'void', + 'cashed_out', + 'canceled', +]); + +const betSchema = z.object({ + id: boundedIdentifier, + userId: boundedIdentifier, + sportsbookSlug: boundedIdentifier, + externalSource: boundedIdentifier.nullish(), + externalBetId: boundedIdentifier.nullish(), + kind: boundedIdentifier, + status: betStatusSchema, + stake: nonNegativeFiniteNumber, + potentialPayout: nonNegativeFiniteNumber.nullish(), + payout: nonNegativeFiniteNumber.nullish(), + market: boundedLabel.nullish(), + league: boundedIdentifier.nullish(), + gameId: boundedIdentifier.nullish(), + side: boundedLabel.nullish(), + line: finiteNumber.nullish(), + americanOdds: americanOdds.nullish(), + placedAt: isoTimestamp, + settledAt: isoTimestamp.nullish(), + visibility: z.enum(['private', 'friends', 'public']).optional(), + fairLine: finiteNumber.nullish(), + edgePercent: finiteNumber.nullish(), +}); + +const summarizeBetHistorySchema = z + .object({ + bets: boundedArray(betSchema, 500), + period: z.enum(['day', 'week', 'month']).optional(), + }) + .superRefine((value, context) => { + const ids = new Set(); + for (const [index, bet] of value.bets.entries()) { + if (ids.has(bet.id)) { + context.addIssue({ + code: 'custom', + path: ['bets', index, 'id'], + message: `Duplicate bet id: ${bet.id}`, + }); + } + ids.add(bet.id); + } + }); + +export const summarizeBetHistoryTool = defineTool({ + name: 'summarize_bet_history', + title: 'Summarize bet history', + description: + 'Summarize up to 500 bet records with @buzzr/bets-core. Returns the overall ' + + 'rollup, UTC period buckets, maximum drawdown, and win/loss streaks.', + inputSchema: summarizeBetHistorySchema, + run: (args) => { + const period = args.period ?? 'month'; + return jsonResult({ + contractVersion: '1', + period, + overall: calculateBetRollup(args.bets), + byPeriod: calculateRollupByPeriod(args.bets, period), + drawdown: calculateDrawdown(args.bets), + streaks: calculateStreaks(args.bets), + }); + }, +}); + +export const historyTools: readonly BuzzrToolDefinition[] = [summarizeBetHistoryTool]; diff --git a/packages/mcp/src/tools/odds.ts b/packages/mcp/src/tools/odds.ts index db3776a..340f6b1 100644 --- a/packages/mcp/src/tools/odds.ts +++ b/packages/mcp/src/tools/odds.ts @@ -1,4 +1,5 @@ import { + calculateClosingLineValue, calculateEdgePercent, calculateExpectedValue, calculateKellyStake, @@ -8,24 +9,40 @@ import { } from '@buzzr/bets-core'; import { z } from 'zod'; +import { americanOdds, positiveFiniteNumber } from './schemas'; import { defineTool, jsonResult } from './shared'; import type { BuzzrToolDefinition } from './shared'; -const americanOdds = z - .number() - .refine((value) => value !== 0, { message: 'American odds cannot be 0.' }) - .describe('American odds, e.g. -110 or +145.'); - const fairLineSchema = z.object({ selected: americanOdds.describe('American odds offered for the side you are evaluating.'), opposite: americanOdds.describe('American odds offered for the other side of the same market.'), selectedSide: z .string() .min(1) + .max(200) .optional() .describe('Optional label for the selected side, e.g. "Lakers -3.5".'), }); +const closingLineValueSchema = z.object({ + placedAmericanOdds: americanOdds.describe('American odds when the bet was placed.'), + closingAmericanOdds: americanOdds.describe('American odds when the market closed.'), +}); + +export const closingLineValueTool = defineTool({ + name: 'closing_line_value', + title: 'Closing line value', + description: + 'Compare placed and closing American odds with @buzzr/bets-core. Returns the ' + + 'implied-probability delta in percentage points and whether the bet beat the close.', + inputSchema: closingLineValueSchema, + run: (args) => + jsonResult({ + contractVersion: '1', + ...calculateClosingLineValue(args), + }), +}); + export const fairLineTool = defineTool({ name: 'fair_line', title: 'No-vig fair line', @@ -52,6 +69,7 @@ const parlayValueSchema = z.object({ }), ) .min(1) + .max(50) .describe('Every leg of the parlay, each with both sides of its market.'), offeredAmericanOdds: americanOdds .optional() @@ -59,9 +77,7 @@ const parlayValueSchema = z.object({ 'Combined parlay price the book is offering. Defaults to the product of the ' + 'selected leg prices when omitted.', ), - stake: z - .number() - .positive() + stake: positiveFiniteNumber .optional() .describe('Optional stake; adds expected-value math to the result.'), }); @@ -104,15 +120,17 @@ export const parlayValueTool = defineTool({ }); const kellyStakeSchema = z.object({ - bankroll: z.number().positive().describe('Total bankroll in currency units.'), + bankroll: positiveFiniteNumber.describe('Total bankroll in currency units.'), americanOdds: americanOdds.describe('American odds offered for the bet.'), winProbability: z .number() + .finite() .gt(0) .lt(1) .describe('Your estimated win probability, strictly between 0 and 1.'), fraction: z .number() + .finite() .gt(0) .max(1) .optional() @@ -140,6 +158,7 @@ export const kellyStakeTool = defineTool({ export const oddsTools: readonly BuzzrToolDefinition[] = [ fairLineTool, + closingLineValueTool, parlayValueTool, kellyStakeTool, ]; diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts new file mode 100644 index 0000000..cccf19e --- /dev/null +++ b/packages/mcp/src/tools/schemas.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +export const MAX_IDENTIFIER_LENGTH = 128; +export const MAX_LABEL_LENGTH = 200; +export const MAX_FINITE_MAGNITUDE = 1_000_000_000; + +export const americanOdds = z + .number() + .finite() + .min(-100_000) + .max(100_000) + .refine((value) => value <= -100 || value >= 100, { + message: 'American odds must be at most -100 or at least +100.', + }); + +export const boundedIdentifier = z.string().min(1).max(MAX_IDENTIFIER_LENGTH); +export const boundedLabel = z.string().min(1).max(MAX_LABEL_LENGTH); +export const finiteNumber = z + .number() + .finite() + .min(-MAX_FINITE_MAGNITUDE) + .max(MAX_FINITE_MAGNITUDE); +export const nonNegativeFiniteNumber = finiteNumber.min(0); +export const positiveFiniteNumber = finiteNumber.positive(); +export const isoTimestamp = z.iso.datetime({ offset: true }); +export const isoDateOrTimestamp = z.union([z.iso.date(), isoTimestamp]); + +/** + * Checks the container size before validating its items. This keeps an + * oversized adversarial array from generating an unbounded Zod issue list. + */ +export function boundedArray( + itemSchema: Schema, + maximum: number, + minimum = 0, +) { + return z + .array(z.unknown()) + .min(minimum) + .max(maximum) + .pipe(z.array(itemSchema).min(minimum).max(maximum)); +} + +/** Checks record cardinality before validating keys and values. */ +export function boundedRecord, ValueSchema extends z.ZodType>( + keySchema: KeySchema, + valueSchema: ValueSchema, + maximum: number, + label: string, +) { + return z + .record(z.string(), z.unknown()) + .refine((value) => Object.keys(value).length <= maximum, { + message: `${label} cannot contain more than ${maximum} entries.`, + }) + .pipe(z.record(keySchema, valueSchema)); +} diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts index f051e16..6438977 100644 --- a/packages/mcp/src/tools/shared.ts +++ b/packages/mcp/src/tools/shared.ts @@ -1,5 +1,25 @@ import type { z } from 'zod'; +const MAX_IN_FLIGHT_CALLS = 32; +const MAX_VALIDATION_ISSUES = 8; +const MAX_VALIDATION_MESSAGE_LENGTH = 200; +const MAX_VALIDATION_PATH_SEGMENTS = 8; +const MAX_VALIDATION_PATH_STRING_LENGTH = 64; +const MAX_TOOL_RESULT_BYTES = 1_048_576; +let processWideInFlightCalls = 0; +const RESULT_SERIALIZATION_FAILED = { + error: { + code: 'result_serialization_failed', + message: 'Tool result could not be serialized.', + }, +}; +const RESULT_TOO_LARGE = { + error: { + code: 'result_too_large', + message: 'Tool result exceeded the maximum response size.', + }, +}; + /** Text content block returned to MCP clients. */ export type ToolTextContent = { type: 'text'; @@ -21,23 +41,58 @@ export type BuzzrToolDefinition = { handler: (args: unknown) => Promise; }; -/** Wraps a value as pretty-printed JSON text content. */ -export function jsonResult(value: unknown): ToolResult { +function fixedErrorResult(value: typeof RESULT_SERIALIZATION_FAILED | typeof RESULT_TOO_LARGE) { return { - content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], + isError: true, + content: [{ type: 'text' as const, text: JSON.stringify(value) }], }; } +function serializeResult(value: unknown, isError = false): ToolResult { + let text: string | undefined; + try { + text = JSON.stringify(value); + } catch { + return fixedErrorResult(RESULT_SERIALIZATION_FAILED); + } + if (text === undefined) { + return fixedErrorResult(RESULT_SERIALIZATION_FAILED); + } + if (Buffer.byteLength(text, 'utf8') > MAX_TOOL_RESULT_BYTES) { + return fixedErrorResult(RESULT_TOO_LARGE); + } + return { + ...(isError ? { isError: true } : {}), + content: [{ type: 'text', text }], + }; +} + +/** Wraps a value as compact JSON text content. */ +export function jsonResult(value: unknown): ToolResult { + return serializeResult(value); +} + /** Wraps an error code + message (and optional details) as an MCP error result. */ export function errorResult(code: string, message: string, details?: unknown): ToolResult { const error: Record = { code, message }; if (details !== undefined) { error.details = details; } - return { - isError: true, - content: [{ type: 'text', text: JSON.stringify({ error }, null, 2) }], - }; + return serializeResult({ error }, true); +} + +function normalizeValidationIssues(issues: readonly z.core.$ZodIssue[]) { + return issues.slice(0, MAX_VALIDATION_ISSUES).map((issue) => ({ + code: String(issue.code).slice(0, 64), + path: issue.path + .slice(0, MAX_VALIDATION_PATH_SEGMENTS) + .map((segment) => + typeof segment === 'number' + ? segment + : String(segment).slice(0, MAX_VALIDATION_PATH_STRING_LENGTH), + ), + message: issue.message.slice(0, MAX_VALIDATION_MESSAGE_LENGTH), + })); } /** @@ -58,21 +113,25 @@ export function defineTool(definition: { description: definition.description, inputSchema: definition.inputSchema, handler: async (args: unknown): Promise => { - const parsed = definition.inputSchema.safeParse(args ?? {}); - if (!parsed.success) { - return errorResult( - 'invalid_input', - `${definition.name}: input failed schema validation.`, - parsed.error.issues, - ); + if (processWideInFlightCalls >= MAX_IN_FLIGHT_CALLS) { + return errorResult('server_busy', 'The server is handling too many requests. Retry later.'); } + + processWideInFlightCalls += 1; try { + const parsed = definition.inputSchema.safeParse(args ?? {}); + if (!parsed.success) { + return errorResult( + 'invalid_input', + `${definition.name}: input failed schema validation.`, + normalizeValidationIssues(parsed.error.issues), + ); + } return await definition.run(parsed.data as z.output); - } catch (error) { - return errorResult( - 'tool_execution_failed', - error instanceof Error ? error.message : String(error), - ); + } catch { + return errorResult('tool_execution_failed', 'Tool execution failed.'); + } finally { + processWideInFlightCalls -= 1; } }, }; diff --git a/packages/mcp/tests/buzz-tools.test.ts b/packages/mcp/tests/buzz-tools.test.ts index da48136..162a804 100644 --- a/packages/mcp/tests/buzz-tools.test.ts +++ b/packages/mcp/tests/buzz-tools.test.ts @@ -60,6 +60,20 @@ describe('predict_game_buzz', () => { expect(result.isError).toBe(true); expect((parseResult(result).error as Record).code).toBe('invalid_input'); }); + + it.each([ + ['invalid startsAt', { ...nbaGame, startsAt: 'tonight' }], + ['oversized team name', { ...nbaGame, homeTeam: 'L'.repeat(201) }], + [ + 'non-finite market data', + { ...nbaGame, odds: { spread: Number.POSITIVE_INFINITY, overUnder: 228.5 } }, + ], + ])('rejects %s at the MCP boundary', async (_label, input) => { + const result = await tool.handler(input); + + expect(result.isError).toBe(true); + expect((parseResult(result).error as Record).code).toBe('invalid_input'); + }); }); describe('rank_games', () => { @@ -144,4 +158,34 @@ describe('rank_games', () => { expect(result.isError).toBe(true); expect((parseResult(result).error as Record).code).toBe('invalid_input'); }); + + it('rejects more than 100 games', async () => { + const tool = createRankGamesTool(baseEngineModule); + const result = await tool.handler({ + games: Array.from({ length: 101 }, (_, index) => ({ + ...nbaGame, + id: `game-${index}`, + })), + }); + + expect(result.isError).toBe(true); + expect((parseResult(result).error as Record).code).toBe('invalid_input'); + }); + + it('rejects oversized affinity maps before validating every value', () => { + const parsed = rankGamesTool.inputSchema.safeParse({ + games: [nbaGame], + profile: { + teamAffinity: Object.fromEntries( + Array.from({ length: 1_000 }, (_, index) => [`team-${index}`, 'invalid']), + ), + }, + }); + + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues).toHaveLength(1); + expect(parsed.error.issues[0].message).toContain('100 entries'); + } + }); }); diff --git a/packages/mcp/tests/dfs-tools.test.ts b/packages/mcp/tests/dfs-tools.test.ts index 9da62f1..728b031 100644 --- a/packages/mcp/tests/dfs-tools.test.ts +++ b/packages/mcp/tests/dfs-tools.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { gradeDfsEntryTool, listBookPoliciesTool, validateDfsEntryTool } from '../src/tools/dfs'; +import { createDfsEngine, DRAFT_BOOK_POLICY_FIXTURES } from '@buzzr/dfs-engine'; + +import { + gradeDfsEntriesTool, + gradeDfsEntryTool, + listBookPoliciesTool, + serializeBatchFailure, + validateDfsEntryTool, +} from '../src/tools/dfs'; import type { ToolResult } from '../src/tools/shared'; function parseResult(result: ToolResult): Record { @@ -40,6 +48,33 @@ function buildEntry(overrides: Record = {}): Record { + return buildEntry({ + entryId: paddedValue(`entry-${entryIndex}-`, 128), + bookId: 'underdog', + playTypeId: 'underdog_standard', + displayedMultiplier: 100, + legs: Array.from({ length: 8 }, (_, legIndex) => ({ + legId: paddedValue(`leg-${entryIndex}-${legIndex}-`, 128), + playerId: paddedValue(`player-${entryIndex}-${legIndex}-`, 128), + playerName: paddedValue(`Player ${entryIndex}-${legIndex} `, 200), + team: paddedValue(`Team ${entryIndex}-${legIndex} `, 200), + opponent: paddedValue(`Opponent ${entryIndex}-${legIndex} `, 200), + gameId: paddedValue(`game-${entryIndex}-${legIndex}-`, 128), + gameDate: '2026-07-16', + league: paddedValue(`league-${entryIndex}-${legIndex}-`, 128), + propType: paddedValue(`prop-${entryIndex}-${legIndex}-`, 128), + line: 25.5, + direction: 'over', + actual: 31, + })), + }); +} + describe('grade_dfs_entry', () => { it('settles a winning PrizePicks power entry with the fixed-table payout', async () => { const result = await gradeDfsEntryTool.handler(buildEntry()); @@ -55,6 +90,25 @@ describe('grade_dfs_entry', () => { expect(Array.isArray(settlement.explanationCodes)).toBe(true); }); + it('preserves the complete settlement contract and adds a human explanation', async () => { + const settlement = parseResult(await gradeDfsEntryTool.handler(buildEntry())); + + expect(settlement).toMatchObject({ + entryId: 'entry-1', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + baseMultiplier: 3, + profitBoostPct: null, + validation: { ok: true, errors: [], warnings: [] }, + }); + expect(settlement.sourceRefs).toEqual(expect.any(Array)); + expect(settlement.provenance).toMatchObject({ providers: expect.any(Array) }); + expect(settlement.auditTrail).toEqual(expect.any(Array)); + expect(settlement.explanation).toContain('entry-1 settled as won'); + }); + it('grades a losing leg as a lost all-or-nothing entry', async () => { const entry = buildEntry(); (entry.legs as Array>)[1].actual = 12; @@ -76,6 +130,22 @@ describe('grade_dfs_entry', () => { expect((settlement.payout as Record).total).toBe(30); }); + it('honors authoritative pre-graded leg statuses without stat values', async () => { + const input = buildEntry(); + for (const leg of input.legs as Array>) { + delete leg.actual; + leg.status = 'won'; + } + + const settlement = parseResult(await gradeDfsEntryTool.handler(input)); + expect(settlement.status).toBe('won'); + expect(settlement.multiplier).toBe(3); + expect(settlement.legs).toEqual([ + expect.objectContaining({ status: 'won', actual: null }), + expect.objectContaining({ status: 'won', actual: null }), + ]); + }); + it('rejects schema-invalid input without touching the engine', async () => { const result = await gradeDfsEntryTool.handler({ bookId: 'prizepicks' }); @@ -83,6 +153,164 @@ describe('grade_dfs_entry', () => { const parsed = parseResult(result); expect((parsed.error as Record).code).toBe('invalid_input'); }); + + it.each([ + ['non-finite numbers', { stake: Number.POSITIVE_INFINITY }], + ['invalid placedAt', { placedAt: 'July 16 sometime' }], + [ + 'duplicate leg ids', + { + legs: [ + (buildEntry().legs as Array>)[0], + (buildEntry().legs as Array>)[0], + ], + }, + ], + [ + 'more than 12 legs', + { + legs: Array.from({ length: 13 }, (_, index) => ({ + ...(buildEntry().legs as Array>)[0], + legId: `leg-${index}`, + })), + }, + ], + ])('rejects %s at the MCP boundary', async (_label, overrides) => { + const result = await gradeDfsEntryTool.handler(buildEntry(overrides)); + + expect(result.isError).toBe(true); + expect(parseResult(result).error as Record).toMatchObject({ + code: 'invalid_input', + }); + }); + + it('does not execute published draft policy fixtures', async () => { + const result = parseResult( + await gradeDfsEntryTool.handler(buildEntry({ bookId: 'sleeper', playTypeId: 'over_under' })), + ); + + expect(result.status).toBe('pending'); + expect(result.pendingReasons).toContain('validation_failed'); + expect(result.explanationCodes).toContain('validation.unknown_book_or_play_type'); + }); +}); + +describe('grade_dfs_entries', () => { + it('settles a bounded batch and returns a versioned, serializable contract', async () => { + const result = await gradeDfsEntriesTool.handler({ + entries: [buildEntry(), buildEntry({ entryId: 'entry-2' })], + concurrency: 2, + }); + + expect(result.isError).toBeUndefined(); + const batch = parseResult(result); + expect(batch).toMatchObject({ + contractVersion: '1', + summary: { total: 2, settled: 2, pending: 0, failed: 0 }, + failures: [], + cache: { providerCalls: 0, cacheHits: 0 }, + }); + expect(batch.results).toEqual([ + expect.objectContaining({ entryId: 'entry-1', explanation: expect.any(String) }), + expect.objectContaining({ entryId: 'entry-2', explanation: expect.any(String) }), + ]); + }); + + it.each([ + [ + 'more than 50 entries', + { + entries: Array.from({ length: 51 }, (_, index) => + buildEntry({ entryId: `entry-${index}` }), + ), + }, + ], + ['concurrency below 1', { entries: [buildEntry()], concurrency: 0 }], + ['concurrency above 8', { entries: [buildEntry()], concurrency: 9 }], + ['duplicate entry ids', { entries: [buildEntry(), buildEntry()] }], + ])('rejects %s', async (_label, input) => { + const result = await gradeDfsEntriesTool.handler(input); + + expect(result.isError).toBe(true); + expect(parseResult(result).error as Record).toMatchObject({ + code: 'invalid_input', + }); + }); + + it('preserves the advertised 50-entry and 600-leg batch boundary', () => { + const maximum = { + entries: Array.from({ length: 50 }, (_, entryIndex) => + buildEntry({ + entryId: `entry-${entryIndex}`, + legs: Array.from({ length: 12 }, (_, legIndex) => ({ + ...(buildEntry().legs as Array>)[0], + legId: `leg-${entryIndex}-${legIndex}`, + })), + }), + ), + }; + const overMaximum = { + entries: [...maximum.entries, buildEntry({ entryId: 'entry-50' })], + }; + + expect(gradeDfsEntriesTool.inputSchema.safeParse(maximum).success).toBe(true); + expect(gradeDfsEntriesTool.inputSchema.safeParse(overMaximum).success).toBe(false); + }); + + it('delivers a fully populated maximum executable batch under the result cap', async () => { + const entries = Array.from({ length: 50 }, (_, entryIndex) => + buildMaximumExecutableEntry(entryIndex), + ); + + const result = await gradeDfsEntriesTool.handler({ entries, concurrency: 8 }); + + expect(result.isError).toBeUndefined(); + expect(Buffer.byteLength(result.content[0].text, 'utf8')).toBeLessThan(1_048_576); + expect(parseResult(result)).toMatchObject({ + summary: { total: 50, settled: 50, pending: 0, failed: 0 }, + }); + }); + + it('delivers the 50-entry and 600-leg schema boundary without a size error', async () => { + const baseLeg = (buildEntry().legs as Array>)[0]; + const entries = Array.from({ length: 50 }, (_, entryIndex) => + buildEntry({ + entryId: `schema-entry-${entryIndex}`, + legs: Array.from({ length: 12 }, (_, legIndex) => ({ + ...baseLeg, + legId: `schema-leg-${entryIndex}-${legIndex}`, + })), + }), + ); + + const result = await gradeDfsEntriesTool.handler({ entries, concurrency: 8 }); + + expect(result.isError).toBeUndefined(); + expect(Buffer.byteLength(result.content[0].text, 'utf8')).toBeLessThan(1_048_576); + expect(parseResult(result)).toMatchObject({ + summary: { total: 50, settled: 0, pending: 50, failed: 0 }, + }); + }); + + it('serializes failures without exposing thrown names, messages, or stacks', () => { + const serialized = serializeBatchFailure({ + entryId: 'entry-failed', + index: 3, + error: new TypeError('policy resolver failed'), + }); + + expect(serialized).toEqual({ + entryId: 'entry-failed', + index: 3, + error: { + code: 'entry_settlement_failed', + message: 'Entry settlement failed.', + }, + }); + expect(JSON.stringify(serialized)).not.toContain('stack'); + expect(JSON.stringify(serialized)).not.toContain('policy resolver failed'); + expect(JSON.stringify(serialized)).not.toContain('TypeError'); + }); }); describe('validate_dfs_entry', () => { @@ -103,10 +331,19 @@ describe('validate_dfs_entry', () => { expect(errors.length).toBeGreaterThan(0); expect(errors.every((issue) => typeof issue.code === 'string')).toBe(true); }); + + it('rejects candidate payloads larger than 64 KiB', async () => { + const result = await validateDfsEntryTool.handler({ + entry: { entryId: 'entry-1', metadata: 'x'.repeat(70_000) }, + }); + + expect(result.isError).toBe(true); + expect((parseResult(result).error as Record).code).toBe('invalid_input'); + }); }); describe('list_book_policies', () => { - it('lists built-in books and draft fixtures with play types', async () => { + it('lists executable policies from the engine authoritative snapshots', async () => { const result = await listBookPoliciesTool.handler({}); expect(result.isError).toBeUndefined(); @@ -114,17 +351,105 @@ describe('list_book_policies', () => { const books = listing.books as Array>; expect(listing.count).toBe(books.length); - const prizepicks = books.find((book) => book.id === 'prizepicks'); - expect(prizepicks).toBeDefined(); - expect(prizepicks?.source).toBe('built_in'); - const playTypeIds = (prizepicks?.playTypes as Array>).map( - (playType) => playType.id, - ); - expect(playTypeIds).toEqual(expect.arrayContaining(['power', 'flex'])); + const snapshots = createDfsEngine().getBookPolicies(); + const executableBooks = books.filter((book) => book.executable === true); + expect(executableBooks).toHaveLength(snapshots.length); + + for (const snapshot of snapshots) { + expect(executableBooks.find((book) => book.id === snapshot.id)).toEqual({ + ...snapshot, + executable: true, + source: 'built_in', + }); + } + + expect(executableBooks.find((book) => book.id === 'prizepicks')).toMatchObject({ + version: '2026-05', + effectiveFrom: '2026-05-01', + status: 'experimental', + verification: { status: 'partial', reviewedAt: '2026-07-16' }, + sources: expect.arrayContaining([ + expect.objectContaining({ + label: expect.any(String), + url: expect.stringMatching(/^https:\/\/www\.prizepicks\.com\//), + retrievedAt: '2026-07-16', + }), + expect.any(Object), + ]), + playTypes: [ + expect.objectContaining({ + id: 'power', + payoutModel: 'fixed-table', + pickCount: { min: 2, max: 6 }, + allOrNothing: true, + scaleDisplayedMultiplier: true, + }), + expect.objectContaining({ + id: 'flex', + payoutModel: 'fixed-table', + pickCount: { min: 3, max: 6 }, + flex: true, + scaleDisplayedMultiplier: true, + }), + ], + }); + + expect(executableBooks.find((book) => book.id === 'underdog')).toMatchObject({ + version: '2026-05', + effectiveFrom: '2026-05-01', + status: 'experimental', + verification: { status: 'unverified', reviewedAt: '2026-07-16' }, + sources: [ + expect.objectContaining({ + label: expect.any(String), + url: 'https://legal.underdogsports.com/', + retrievedAt: '2026-07-16', + }), + ], + playTypes: [ + expect.objectContaining({ + id: 'underdog_standard', + payoutModel: 'fixed-table', + pickCount: { min: 2, max: 8 }, + allOrNothing: true, + scaleDisplayedMultiplier: true, + }), + expect.objectContaining({ + id: 'underdog_flex', + payoutModel: 'fixed-table', + pickCount: { min: 3, max: 8 }, + flex: true, + scaleDisplayedMultiplier: true, + }), + ], + }); + }); + + it('keeps every draft fixture complete and explicitly metadata-only', async () => { + const listing = parseResult(await listBookPoliciesTool.handler({})); + const books = listing.books as Array>; + const draftBooks = books.filter((book) => book.source === 'draft_fixture'); + + expect(draftBooks).toHaveLength(DRAFT_BOOK_POLICY_FIXTURES.length); + for (const fixture of DRAFT_BOOK_POLICY_FIXTURES) { + expect(draftBooks.find((book) => book.id === fixture.id)).toEqual({ + id: fixture.id, + displayName: fixture.displayName, + version: fixture.version, + effectiveFrom: fixture.effectiveFrom, + status: fixture.status, + verification: fixture.verification ?? null, + sources: fixture.sources, + playTypes: fixture.playTypes, + executable: false, + source: 'draft_fixture', + }); + } const sleeper = books.find((book) => book.id === 'sleeper'); expect(sleeper).toBeDefined(); expect(sleeper?.status).toBe('draft'); expect(sleeper?.source).toBe('draft_fixture'); + expect(sleeper?.executable).toBe(false); }); }); diff --git a/packages/mcp/tests/history-tools.test.ts b/packages/mcp/tests/history-tools.test.ts new file mode 100644 index 0000000..6590bcd --- /dev/null +++ b/packages/mcp/tests/history-tools.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeBetHistoryTool } from '../src/tools/history'; +import type { ToolResult } from '../src/tools/shared'; + +function parseResult(result: ToolResult): Record { + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + return JSON.parse(result.content[0].text) as Record; +} + +function bet(overrides: Record = {}): Record { + return { + id: 'bet-1', + userId: 'user-1', + sportsbookSlug: 'draftkings', + kind: 'straight', + status: 'won', + stake: 10, + payout: 25, + placedAt: '2026-07-15T17:00:00.000Z', + settledAt: '2026-07-15T18:00:00.000Z', + ...overrides, + }; +} + +describe('summarize_bet_history', () => { + it('returns rollup, period, drawdown, and streak analytics in one versioned result', async () => { + const result = await summarizeBetHistoryTool.handler({ + bets: [ + bet(), + bet({ + id: 'bet-2', + status: 'lost', + payout: 0, + settledAt: '2026-07-16T18:00:00.000Z', + }), + ], + period: 'day', + }); + + expect(result.isError).toBeUndefined(); + expect(parseResult(result)).toEqual({ + contractVersion: '1', + period: 'day', + overall: expect.objectContaining({ totalBets: 2, won: 1, lost: 1, netUnits: 5 }), + byPeriod: [ + expect.objectContaining({ periodStart: '2026-07-15' }), + expect.objectContaining({ periodStart: '2026-07-16' }), + ], + drawdown: { + maxDrawdownUnits: 10, + peakUnits: 15, + troughUnits: 0, + currentUnits: 5, + }, + streaks: { + longestWinStreak: 1, + longestLossStreak: 1, + currentStreak: { status: 'lost', count: 1 }, + }, + }); + }); + + it.each([ + [ + 'more than 500 bets', + { bets: Array.from({ length: 501 }, (_, index) => bet({ id: `bet-${index}` })) }, + ], + ['duplicate bet ids', { bets: [bet(), bet()] }], + ['invalid placedAt', { bets: [bet({ placedAt: 'yesterday' })] }], + ['invalid settledAt', { bets: [bet({ settledAt: 'tomorrow maybe' })] }], + ['non-finite stake', { bets: [bet({ stake: Number.NaN })] }], + ['American odds between -100 and +100', { bets: [bet({ americanOdds: 99 })] }], + ['American odds above the magnitude limit', { bets: [bet({ americanOdds: -100_001 })] }], + ])('rejects %s', async (_label, input) => { + const result = await summarizeBetHistoryTool.handler(input); + + expect(result.isError).toBe(true); + expect(parseResult(result).error as Record).toMatchObject({ + code: 'invalid_input', + }); + }); +}); diff --git a/packages/mcp/tests/odds-tools.test.ts b/packages/mcp/tests/odds-tools.test.ts index e52e3ac..d4de8a4 100644 --- a/packages/mcp/tests/odds-tools.test.ts +++ b/packages/mcp/tests/odds-tools.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { fairLineTool, kellyStakeTool, parlayValueTool } from '../src/tools/odds'; +import { + closingLineValueTool, + fairLineTool, + kellyStakeTool, + parlayValueTool, +} from '../src/tools/odds'; import type { ToolResult } from '../src/tools/shared'; function parseResult(result: ToolResult): Record { @@ -36,6 +41,61 @@ describe('fair_line', () => { expect(result.isError).toBe(true); expect((parseResult(result).error as Record).code).toBe('invalid_input'); }); + + it('rejects an oversized selected-side label', async () => { + const result = await fairLineTool.handler({ + selected: -110, + opposite: -110, + selectedSide: 'L'.repeat(201), + }); + + expect(result.isError).toBe(true); + expect((parseResult(result).error as Record).code).toBe('invalid_input'); + }); +}); + +describe('closing_line_value', () => { + it('computes a versioned implied-probability delta', async () => { + const result = await closingLineValueTool.handler({ + placedAmericanOdds: 110, + closingAmericanOdds: -105, + }); + + expect(result.isError).toBeUndefined(); + expect(parseResult(result)).toEqual({ + contractVersion: '1', + clvPercent: 3.6, + beatClosingLine: true, + }); + }); + + it.each([ + ['zero odds', { placedAmericanOdds: 0, closingAmericanOdds: -110 }], + ['positive odds below +100', { placedAmericanOdds: 99, closingAmericanOdds: -110 }], + ['negative odds above -100', { placedAmericanOdds: -99, closingAmericanOdds: -110 }], + ['positive odds above +100000', { placedAmericanOdds: 100_001, closingAmericanOdds: -110 }], + ['negative odds below -100000', { placedAmericanOdds: -100_001, closingAmericanOdds: -110 }], + [ + 'non-finite odds', + { placedAmericanOdds: Number.NEGATIVE_INFINITY, closingAmericanOdds: -110 }, + ], + ])('rejects %s', async (_label, input) => { + const result = await closingLineValueTool.handler(input); + + expect(result.isError).toBe(true); + expect(parseResult(result).error as Record).toMatchObject({ + code: 'invalid_input', + }); + }); + + it.each([-100_000, -100, 100, 100_000])('accepts boundary American odds %d', async (odds) => { + const result = await closingLineValueTool.handler({ + placedAmericanOdds: odds, + closingAmericanOdds: -110, + }); + + expect(result.isError).toBeUndefined(); + }); }); describe('parlay_value', () => { @@ -75,6 +135,15 @@ describe('parlay_value', () => { expect(result.isError).toBe(true); expect((parseResult(result).error as Record).code).toBe('invalid_input'); }); + + it('rejects more than 50 parlay legs', async () => { + const result = await parlayValueTool.handler({ + legs: Array.from({ length: 51 }, () => ({ selected: -110, opposite: -110 })), + }); + + expect(result.isError).toBe(true); + expect((parseResult(result).error as Record).code).toBe('invalid_input'); + }); }); describe('kelly_stake', () => { diff --git a/packages/mcp/tests/release-contract.test.ts b/packages/mcp/tests/release-contract.test.ts new file mode 100644 index 0000000..a758536 --- /dev/null +++ b/packages/mcp/tests/release-contract.test.ts @@ -0,0 +1,136 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageManifest = JSON.parse(readFileSync(resolve(here, '../package.json'), 'utf8')) as { + dependencies: Record; +}; +const engineManifest = JSON.parse( + readFileSync(resolve(here, '../../dfs-engine/package.json'), 'utf8'), +) as { version: string }; +const betsManifest = JSON.parse( + readFileSync(resolve(here, '../../bets-core/package.json'), 'utf8'), +) as { version: string }; +const entertainmentManifest = JSON.parse( + readFileSync(resolve(here, '../../entertainment-engine/package.json'), 'utf8'), +) as { version: string }; +const serverSource = readFileSync(resolve(here, '../src/server.ts'), 'utf8'); +const cliSource = readFileSync(resolve(here, '../src/cli.ts'), 'utf8'); +const publishedProof = readFileSync( + resolve(here, '../../../scripts/prove-mcp-published.mjs'), + 'utf8', +); +const rootManifest = JSON.parse(readFileSync(resolve(here, '../../../package.json'), 'utf8')) as { + scripts: Record; +}; +const ciWorkflow = readFileSync(resolve(here, '../../../.github/workflows/ci.yml'), 'utf8'); +const packedReleaseProofPath = resolve(here, '../../../scripts/test-release-artifacts.mjs'); + +describe('release safety contracts', () => { + it('proves every public package from clean packed artifacts on Node 22', () => { + expect(rootManifest.scripts['test:packages:packed']).toBe( + 'node scripts/test-release-artifacts.mjs', + ); + expect(rootManifest.scripts.verify).toContain('npm run test:packages:packed'); + expect(ciWorkflow).toContain('npm run test:packages:packed'); + expect(existsSync(packedReleaseProofPath)).toBe(true); + + if (!existsSync(packedReleaseProofPath)) return; + const packedReleaseProof = readFileSync(packedReleaseProofPath, 'utf8'); + for (const packageName of [ + '@buzzr/bets-core', + '@buzzr/dfs-cli', + '@buzzr/dfs-engine', + '@buzzr/dfs-engine-test-vectors', + '@buzzr/dfs-provider-espn', + '@buzzr/dfs-provider-sportradar', + '@buzzr/dfs-react', + '@buzzr/dfs-testkit', + '@buzzr/entertainment-engine', + '@buzzr/mcp', + ]) { + expect(packedReleaseProof).toContain(`'${packageName}'`); + } + expect(packedReleaseProof).toContain("'--ignore-scripts'"); + expect(packedReleaseProof).toContain("'--package-lock=false'"); + }); + + it('derives the advertised server version from the package manifest', () => { + expect(serverSource).toContain("from '../package.json'"); + expect(serverSource).not.toMatch(/SERVER_VERSION\s*=\s*['"`]\d/); + }); + + it('does not interpolate startup exception details into public stderr', () => { + expect(cliSource).not.toContain('error instanceof Error ? error.message'); + expect(cliSource).not.toContain('String(error)'); + }); + + it('pins all internal Buzzr runtime dependencies exactly', () => { + expect(packageManifest.dependencies).toMatchObject({ + '@buzzr/bets-core': betsManifest.version, + '@buzzr/dfs-engine': engineManifest.version, + '@buzzr/entertainment-engine': entertainmentManifest.version, + }); + }); + + it('runs the published npx proof through Node and the npm-provided npx CLI', () => { + expect(publishedProof).toContain('process.env.npm_execpath'); + expect(publishedProof).toContain("'npx-cli.js'"); + expect(publishedProof).not.toContain("process.platform === 'win32' ? 'npx.cmd' : 'npx'"); + }); + + it('proves all eleven public tools in the reviewed release contract', () => { + for (const toolName of [ + 'grade_dfs_entry', + 'grade_dfs_entries', + 'validate_dfs_entry', + 'list_book_policies', + 'fair_line', + 'closing_line_value', + 'parlay_value', + 'kelly_stake', + 'summarize_bet_history', + 'predict_game_buzz', + 'rank_games', + ]) { + expect(publishedProof).toContain(`'${toolName}'`); + } + expect(publishedProof).toContain('Published MCP DFS batch call'); + expect(publishedProof).toContain('Published MCP closing line call'); + expect(publishedProof).toContain('Published MCP bet history call'); + }); + + it('binds the live npm proof to reviewed integrity, git head, provenance, and registry origin', () => { + expect(publishedProof).toContain('EXPECTED_MCP_INTEGRITY'); + expect(publishedProof).toContain('EXPECTED_GIT_HEAD'); + expect(publishedProof).toContain("'dist.attestations.provenance'"); + expect(publishedProof).toContain("'dist.tarball'"); + expect(publishedProof).toContain("'gitHead'"); + expect(publishedProof).toContain("'https://registry.npmjs.org'"); + expect(publishedProof).toContain('assert.equal(integrity, expectedIntegrity'); + expect(publishedProof).toContain('assert.equal(gitHead, expectedGitHead'); + }); + + it('runs npm and npx with isolated home, app-data, temp, registry, and config paths', () => { + for (const variable of [ + 'HOME', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'TEMP', + 'TMP', + 'TMPDIR', + 'npm_config_userconfig', + 'npm_config_globalconfig', + 'npm_config_registry', + 'npm_config_ignore_scripts', + ]) { + expect(publishedProof).toContain(`${variable}:`); + } + expect(publishedProof).toContain("npm_config_registry: 'https://registry.npmjs.org/'"); + expect(publishedProof).toContain("npm_config_ignore_scripts: 'true'"); + expect(publishedProof).not.toMatch(/const inheritedEnvironmentKeys = \[[\s\S]*?'HOME'/); + }); +}); diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 4333ab6..918e97c 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -1,22 +1,46 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; -import { allTools, createBuzzrMcpServer, SERVER_NAME, SERVER_VERSION } from '../src/index'; +import { + allTools, + createBuzzrMcpServer, + fairLineTool, + registerBuzzrTool, + SERVER_NAME, + SERVER_VERSION, +} from '../src/index'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageManifest = JSON.parse(readFileSync(resolve(here, '../package.json'), 'utf8')) as { + version: string; +}; const EXPECTED_TOOL_NAMES = [ 'grade_dfs_entry', + 'grade_dfs_entries', 'validate_dfs_entry', 'list_book_policies', 'fair_line', + 'closing_line_value', 'parlay_value', 'kelly_stake', + 'summarize_bet_history', 'predict_game_buzz', 'rank_games', ]; describe('tool catalog', () => { - it('ships all eight engine tools', () => { + it('advertises the package manifest version', () => { + expect(SERVER_VERSION).toBe(packageManifest.version); + }); + + it('ships all eleven engine tools', () => { expect(allTools.map((tool) => tool.name)).toEqual(EXPECTED_TOOL_NAMES); }); @@ -46,6 +70,29 @@ describe('createBuzzrMcpServer', () => { expect(listed.tools.map((tool) => tool.name).sort()).toEqual([...EXPECTED_TOOL_NAMES].sort()); const fairLine = listed.tools.find((tool) => tool.name === 'fair_line'); expect(fairLine?.inputSchema).toMatchObject({ type: 'object' }); + const batch = listed.tools.find((tool) => tool.name === 'grade_dfs_entries'); + expect(batch?.inputSchema).toMatchObject({ + type: 'object', + properties: { + entries: { + type: 'array', + minItems: 1, + maxItems: 50, + items: { + type: 'object', + properties: { + entryId: { type: 'string' }, + legs: { + type: 'array', + minItems: 1, + maxItems: 12, + items: { type: 'object' }, + }, + }, + }, + }, + }, + }); const callResult = await client.callTool({ name: 'fair_line', @@ -60,4 +107,74 @@ describe('createBuzzrMcpServer', () => { await server.close(); } }); + + it('bounds transport-level validation errors before the SDK can amplify them', async () => { + const server = createBuzzrMcpServer(); + const client = new Client({ name: 'buzzr-mcp-adversarial-test', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const result = await client.callTool({ + name: 'summarize_bet_history', + arguments: { bets: Array.from({ length: 5_000 }, () => ({})) }, + }); + const content = result.content as Array<{ type: string; text: string }>; + + expect(result.isError).toBe(true); + expect(Buffer.byteLength(JSON.stringify(result), 'utf8')).toBeLessThan(65_536); + expect(JSON.parse(content[0].text)).toMatchObject({ + error: { code: 'invalid_input' }, + }); + } finally { + await client.close(); + await server.close(); + } + }); + + it('coexists with SDK-native tools added to the created server', async () => { + const server = createBuzzrMcpServer(); + server.registerTool( + 'host_native_tool', + { inputSchema: z.object({ value: z.string() }) }, + ({ value }) => ({ content: [{ type: 'text', text: value }] }), + ); + const client = new Client({ name: 'buzzr-mcp-embed-test', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toContain('host_native_tool'); + const result = await client.callTool({ + name: 'host_native_tool', + arguments: { value: 'native-ok' }, + }); + expect(result.content).toEqual([{ type: 'text', text: 'native-ok' }]); + } finally { + await client.close(); + await server.close(); + } + }); + + it('adds Buzzr tools without hiding SDK-native tools registered first', async () => { + const server = new McpServer({ name: 'embedded-host', version: '1.0.0' }); + server.registerTool('host_first', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'host-first-ok' }], + })); + registerBuzzrTool(server, fairLineTool); + + const client = new Client({ name: 'buzzr-mcp-register-test', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + expect((await client.listTools()).tools.map((tool) => tool.name).sort()).toEqual([ + 'fair_line', + 'host_first', + ]); + } finally { + await client.close(); + await server.close(); + } + }); }); diff --git a/packages/mcp/tests/shared.test.ts b/packages/mcp/tests/shared.test.ts new file mode 100644 index 0000000..bfb3d66 --- /dev/null +++ b/packages/mcp/tests/shared.test.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import { describe, expect, it } from 'vitest'; + +import { defineTool, errorResult, jsonResult } from '../src/tools/shared'; +import type { ToolResult } from '../src/tools/shared'; + +function parseResult(result: ToolResult): Record { + expect(result.content).toHaveLength(1); + return JSON.parse(result.content[0].text) as Record; +} + +describe('tool handler safety boundary', () => { + it('returns a stable public error without exposing thrown internals', async () => { + const tool = defineTool({ + name: 'throws_secret', + title: 'Throws secret', + description: 'Test-only tool.', + inputSchema: z.object({}), + run: () => { + throw new TypeError('database password was hunter2'); + }, + }); + + const result = await tool.handler({}); + const error = parseResult(result).error as Record; + + expect(result.isError).toBe(true); + expect(error).toEqual({ + code: 'tool_execution_failed', + message: 'Tool execution failed.', + }); + expect(result.content[0].text).not.toContain('hunter2'); + expect(result.content[0].text).not.toContain('TypeError'); + }); + + it('caps and normalizes schema-validation details', async () => { + const tool = defineTool({ + name: 'bounded_validation', + title: 'Bounded validation', + description: 'Test-only tool.', + inputSchema: z.object({ + values: z.array(z.string().max(1)).max(4), + }), + run: () => jsonResult({ ok: true }), + }); + + const result = await tool.handler({ values: Array.from({ length: 50 }, () => 'too-long') }); + const error = parseResult(result).error as Record; + const details = error.details as Array>; + + expect(details.length).toBeLessThanOrEqual(8); + for (const detail of details) { + expect(Object.keys(detail).sort()).toEqual(['code', 'message', 'path']); + expect(String(detail.message).length).toBeLessThanOrEqual(200); + expect((detail.path as unknown[]).length).toBeLessThanOrEqual(8); + } + }); + + it('rejects a serialized tool result larger than one MiB', async () => { + const tool = defineTool({ + name: 'oversized_result', + title: 'Oversized result', + description: 'Test-only tool.', + inputSchema: z.object({}), + run: () => jsonResult({ payload: 'x'.repeat(1_048_576) }), + }); + + const result = await tool.handler({}); + + expect(result.isError).toBe(true); + expect(parseResult(result).error).toEqual({ + code: 'result_too_large', + message: 'Tool result exceeded the maximum response size.', + }); + expect(result.content[0].text).not.toContain('xxxxx'); + }); + + it('applies the response cap to exported error details too', () => { + const result = errorResult('upstream_failed', 'Upstream failed.', { + debug: 'secret'.repeat(200_000), + }); + + expect(result.isError).toBe(true); + expect(parseResult(result).error).toEqual({ + code: 'result_too_large', + message: 'Tool result exceeded the maximum response size.', + }); + expect(result.content[0].text).not.toContain('secret'); + }); + + it('allows 32 in-flight calls and rejects the next until capacity returns', async () => { + const releases: Array<() => void> = []; + const tool = defineTool({ + name: 'bounded_concurrency', + title: 'Bounded concurrency', + description: 'Test-only tool.', + inputSchema: z.object({ index: z.number().int() }), + run: async ({ index }) => { + await new Promise((resolve) => releases.push(resolve)); + return jsonResult({ index }); + }, + }); + + const accepted = Array.from({ length: 32 }, (_, index) => tool.handler({ index })); + await Promise.resolve(); + + const rejected = await tool.handler({ index: 32 }); + expect(rejected.isError).toBe(true); + expect(parseResult(rejected).error).toEqual({ + code: 'server_busy', + message: 'The server is handling too many requests. Retry later.', + }); + + for (const release of releases) { + release(); + } + const completed = await Promise.all(accepted); + expect(completed.every((result) => result.isError === undefined)).toBe(true); + + const recovered = tool.handler({ index: 33 }); + await Promise.resolve(); + releases.at(-1)?.(); + expect((parseResult(await recovered).index as number) ?? -1).toBe(33); + }); + + it('shares the 32-call limit across distinct tool definitions', async () => { + const releases: Array<() => void> = []; + const buildHeldTool = (name: string) => + defineTool({ + name, + title: name, + description: 'Test-only tool.', + inputSchema: z.object({ index: z.number().int() }), + run: async ({ index }) => { + await new Promise((resolve) => releases.push(resolve)); + return jsonResult({ index }); + }, + }); + const firstTool = buildHeldTool('global_limit_first'); + const secondTool = buildHeldTool('global_limit_second'); + + const accepted = [ + ...Array.from({ length: 20 }, (_, index) => firstTool.handler({ index })), + ...Array.from({ length: 12 }, (_, index) => secondTool.handler({ index: index + 20 })), + ]; + await Promise.resolve(); + + const extraCall = secondTool.handler({ index: 32 }); + let observed: ToolResult | null = null; + try { + observed = await Promise.race([ + extraCall, + new Promise((resolve) => setImmediate(() => resolve(null))), + ]); + + expect(observed).not.toBeNull(); + expect(observed?.isError).toBe(true); + expect(parseResult(observed as ToolResult).error).toEqual({ + code: 'server_busy', + message: 'The server is handling too many requests. Retry later.', + }); + } finally { + for (const release of releases) { + release(); + } + await Promise.all([...accepted, extraCall]); + } + }); +}); diff --git a/packages/mcp/tests/stdin-frame-limiter.test.ts b/packages/mcp/tests/stdin-frame-limiter.test.ts new file mode 100644 index 0000000..6ae148b --- /dev/null +++ b/packages/mcp/tests/stdin-frame-limiter.test.ts @@ -0,0 +1,90 @@ +import { finished } from 'node:stream/promises'; +import { describe, expect, it } from 'vitest'; + +import { + BoundedNewlineInput, + MAX_STDIN_FRAME_BYTES, + StdinFrameLimitError, +} from '../src/stdin-frame-limiter'; + +async function transformChunks(chunks: readonly Buffer[]) { + const input = new BoundedNewlineInput(); + const output: Buffer[] = []; + input.on('data', (chunk: Buffer) => output.push(chunk)); + + const completion = finished(input).then( + () => null, + (error: unknown) => error, + ); + for (const chunk of chunks) { + if (!input.destroyed) { + input.write(chunk); + } + } + input.end(); + + return { + output: Buffer.concat(output), + error: await completion, + }; +} + +describe('BoundedNewlineInput', () => { + it('accepts an exact-limit newline-delimited frame', async () => { + const payload = Buffer.alloc(MAX_STDIN_FRAME_BYTES, 0x61); + const result = await transformChunks([payload, Buffer.from('\n')]); + + expect(result.error).toBeNull(); + expect(result.output).toHaveLength(MAX_STDIN_FRAME_BYTES + 1); + expect(result.output[0]).toBe(0x61); + expect(result.output[MAX_STDIN_FRAME_BYTES - 1]).toBe(0x61); + expect(result.output[MAX_STDIN_FRAME_BYTES]).toBe(0x0a); + }); + + it('buffers split chunks by reference until the newline arrives', async () => { + const result = await transformChunks([ + Buffer.from('{"jsonrpc":"2.0",'), + Buffer.from('"id":1}'), + Buffer.from('\n'), + ]); + + expect(result.error).toBeNull(); + expect(result.output.toString()).toBe('{"jsonrpc":"2.0","id":1}\n'); + }); + + it('accepts an exact-limit CRLF frame without counting the carriage return', async () => { + const payload = Buffer.alloc(MAX_STDIN_FRAME_BYTES, 0x62); + const result = await transformChunks([payload, Buffer.from('\r'), Buffer.from('\n')]); + + expect(result.error).toBeNull(); + expect(result.output).toHaveLength(MAX_STDIN_FRAME_BYTES + 2); + expect(result.output[0]).toBe(0x62); + expect(result.output[MAX_STDIN_FRAME_BYTES - 1]).toBe(0x62); + expect(result.output[MAX_STDIN_FRAME_BYTES]).toBe(0x0d); + expect(result.output[MAX_STDIN_FRAME_BYTES + 1]).toBe(0x0a); + }); + + it('emits multiple complete frames from one input chunk', async () => { + const result = await transformChunks([Buffer.from('{"id":1}\n{"id":2}\r\n')]); + + expect(result.error).toBeNull(); + expect(result.output.toString()).toBe('{"id":1}\n{"id":2}\r\n'); + }); + + it('rejects a frame one byte over the limit', async () => { + const result = await transformChunks([ + Buffer.alloc(MAX_STDIN_FRAME_BYTES + 1, 0x63), + Buffer.from('\n'), + ]); + + expect(result.error).toBeInstanceOf(StdinFrameLimitError); + expect(result.output).toHaveLength(0); + }); + + it('discards an incomplete final frame at EOF', async () => { + const result = await transformChunks([Buffer.from('{"jsonrpc":')]); + + expect(result.error).toBeNull(); + expect(result.output).toHaveLength(0); + }); +}); diff --git a/release-manifest.json b/release-manifest.json new file mode 100644 index 0000000..f31baa4 --- /dev/null +++ b/release-manifest.json @@ -0,0 +1,101 @@ +{ + "releaseVersion": "5.1.0", + "tag": "v5.1.0", + "registry": { + "npm": "https://registry.npmjs.org/", + "mcpApi": "https://registry.modelcontextprotocol.io/v0.1", + "mcpServerName": "io.github.Buzzr-app/dfs-engine", + "mcpPackage": "@buzzr/mcp", + "repository": "https://github.com/Buzzr-app/dfs-engine", + "workflowPath": ".github/workflows/release.yml", + "ref": "refs/heads/main" + }, + "packages": [ + { + "name": "@buzzr/bets-core", + "path": "packages/bets-core", + "version": "5.0.0", + "publish": false, + "internalDependencies": {} + }, + { + "name": "@buzzr/dfs-cli", + "path": "packages/dfs-cli", + "version": "5.0.1", + "publish": true, + "internalDependencies": { + "@buzzr/dfs-engine": "^5.1.0" + } + }, + { + "name": "@buzzr/dfs-engine", + "path": "packages/dfs-engine", + "version": "5.1.0", + "publish": true, + "internalDependencies": {} + }, + { + "name": "@buzzr/dfs-engine-test-vectors", + "path": "packages/dfs-engine-test-vectors", + "version": "5.1.0", + "publish": true, + "internalDependencies": { + "@buzzr/dfs-engine": "5.1.0" + } + }, + { + "name": "@buzzr/dfs-provider-espn", + "path": "packages/dfs-provider-espn", + "version": "5.0.0", + "publish": false, + "internalDependencies": { + "@buzzr/dfs-engine": "^5.0.0" + } + }, + { + "name": "@buzzr/dfs-provider-sportradar", + "path": "packages/dfs-provider-sportradar", + "version": "5.0.0", + "publish": false, + "internalDependencies": { + "@buzzr/dfs-engine": "^5.0.0" + } + }, + { + "name": "@buzzr/dfs-react", + "path": "packages/dfs-react", + "version": "5.0.0", + "publish": false, + "internalDependencies": { + "@buzzr/dfs-engine": "^5.0.0" + } + }, + { + "name": "@buzzr/dfs-testkit", + "path": "packages/dfs-testkit", + "version": "5.0.1", + "publish": true, + "internalDependencies": { + "@buzzr/dfs-engine": "^5.1.0" + } + }, + { + "name": "@buzzr/entertainment-engine", + "path": "packages/entertainment-engine", + "version": "5.0.0", + "publish": false, + "internalDependencies": {} + }, + { + "name": "@buzzr/mcp", + "path": "packages/mcp", + "version": "5.1.0", + "publish": true, + "internalDependencies": { + "@buzzr/bets-core": "5.0.0", + "@buzzr/dfs-engine": "5.1.0", + "@buzzr/entertainment-engine": "5.0.0" + } + } + ] +} diff --git a/scripts/capture-adoption-metrics.mjs b/scripts/capture-adoption-metrics.mjs new file mode 100644 index 0000000..98b8b5d --- /dev/null +++ b/scripts/capture-adoption-metrics.mjs @@ -0,0 +1,421 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +export const NPM_PACKAGES = Object.freeze([ + '@buzzr/bets-core', + '@buzzr/dfs-cli', + '@buzzr/dfs-engine', + '@buzzr/dfs-engine-test-vectors', + '@buzzr/dfs-provider-espn', + '@buzzr/dfs-provider-sportradar', + '@buzzr/dfs-react', + '@buzzr/dfs-testkit', + '@buzzr/entertainment-engine', + '@buzzr/mcp', +]); + +export const MCP_REGISTRY_NAME = 'io.github.Buzzr-app/dfs-engine'; + +const REPOSITORY = 'Buzzr-app/dfs-engine'; +const NPM_DOWNLOADS_API = 'https://api.npmjs.org/downloads/point'; +const GITHUB_API = 'https://api.github.com'; +const MCP_REGISTRY_API = 'https://registry.modelcontextprotocol.io/v0.1/servers'; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function parseDateOnly(value, label) { + if (!DATE_PATTERN.test(value)) { + throw new Error(`${label} must use YYYY-MM-DD.`); + } + + const parsed = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) { + throw new Error(`${label} must be a real UTC date in YYYY-MM-DD format.`); + } + + return parsed; +} + +export function validateCompleteUtcWindow(startDate, endDate, now = new Date()) { + const start = parseDateOnly(startDate, '--start'); + const end = parseDateOnly(endDate, '--end'); + if (start > end) { + throw new Error('--start must not be after --end.'); + } + + const currentUtcDate = now.toISOString().slice(0, 10); + if (endDate >= currentUtcDate) { + throw new Error('--end must be earlier than the current UTC date so every day is complete.'); + } + + return { + startDate, + endDate, + timezone: 'UTC', + inclusivity: 'start and end dates inclusive', + completeDaysOnly: true, + }; +} + +export function parseArguments(arguments_) { + const values = {}; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument !== '--start' && argument !== '--end') { + throw new Error(`Unknown argument: ${argument}`); + } + + const value = arguments_[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${argument} requires a YYYY-MM-DD value.`); + } + values[argument === '--start' ? 'startDate' : 'endDate'] = value; + index += 1; + } + + if (!values.startDate || !values.endDate) { + throw new Error('--start and --end are required.'); + } + + return values; +} + +function normalizeCapturedAt(capturedAt) { + const value = capturedAt instanceof Date ? capturedAt : new Date(capturedAt); + if (Number.isNaN(value.valueOf())) { + throw new Error('capturedAt must be a valid timestamp.'); + } + return value.toISOString(); +} + +function requireNumber(value, label) { + if (!Number.isFinite(value)) { + throw new Error(`${label} was not a finite number.`); + } + return value; +} + +function requireArray(value, label) { + if (!Array.isArray(value)) { + throw new Error(`${label} was not an array.`); + } + return value; +} + +async function requestJson(fetchImplementation, url, init = {}) { + const response = await fetchImplementation(url, { + ...init, + signal: init.signal ?? AbortSignal.timeout(20_000), + }); + const body = await response.text(); + + let parsed; + try { + parsed = body ? JSON.parse(body) : null; + } catch { + throw new Error(`GET ${url} returned non-JSON data (${response.status}).`); + } + + if (!response.ok) { + const detail = parsed?.message ?? parsed?.error ?? body.slice(0, 200) ?? 'unknown error'; + throw new Error(`GET ${url} failed (${response.status}): ${detail}`); + } + + return parsed; +} + +function createGithubHeaders(githubToken) { + if (!githubToken?.trim()) { + throw new Error( + 'GitHub traffic requires GH_TOKEN/GITHUB_TOKEN or a working `gh auth token` login.', + ); + } + + return { + accept: 'application/vnd.github+json', + authorization: `Bearer ${githubToken.trim()}`, + 'user-agent': 'buzzr-adoption-capture', + 'x-github-api-version': '2022-11-28', + }; +} + +function githubUrl(path, parameters = {}) { + const url = new URL(path, GITHUB_API); + for (const [name, value] of Object.entries(parameters)) { + if (value !== undefined) url.searchParams.set(name, String(value)); + } + return url.href; +} + +async function fetchAllGithubIssues({ fetchImplementation, headers, state, since }) { + const issues = []; + let page = 1; + let firstRequestUrl; + + while (page <= 1_000) { + const requestUrl = githubUrl(`/repos/${REPOSITORY}/issues`, { + state, + per_page: 100, + since, + page, + }); + firstRequestUrl ??= requestUrl; + const batch = requireArray( + await requestJson(fetchImplementation, requestUrl, { headers }), + `GitHub ${state} issues`, + ); + issues.push(...batch); + if (batch.length < 100) return { issues, sourceUrl: firstRequestUrl }; + page += 1; + } + + throw new Error('GitHub issues pagination exceeded 1,000 pages.'); +} + +async function captureNpm({ startDate, endDate, fetchImplementation, window }) { + const packages = await Promise.all( + NPM_PACKAGES.map(async (name) => { + const sourceUrl = `${NPM_DOWNLOADS_API}/${startDate}:${endDate}/${encodeURIComponent(name)}`; + const response = await requestJson(fetchImplementation, sourceUrl); + if (response.package !== name || response.start !== startDate || response.end !== endDate) { + throw new Error(`npm returned mismatched package or window metadata for ${name}.`); + } + return { + name, + downloads: requireNumber(response.downloads, `${name} downloads`), + sourceUrl, + }; + }), + ); + + return { + source: { + api: NPM_DOWNLOADS_API, + documentation: 'https://github.com/npm/registry/blob/main/docs/download-counts.md', + window, + }, + packages, + familyTotal: packages.reduce((sum, package_) => sum + package_.downloads, 0), + interpretation: { + unit: 'package downloads', + uniqueUsers: false, + note: 'One consumer can download multiple packages or download the same package more than once.', + }, + }; +} + +async function captureGithub({ startDate, endDate, fetchImplementation, githubToken }) { + const headers = createGithubHeaders(githubToken); + const repoUrl = githubUrl(`/repos/${REPOSITORY}`); + const clonesUrl = githubUrl(`/repos/${REPOSITORY}/traffic/clones`, { per: 'day' }); + const viewsUrl = githubUrl(`/repos/${REPOSITORY}/traffic/views`, { per: 'day' }); + const referrersUrl = githubUrl(`/repos/${REPOSITORY}/traffic/popular/referrers`); + const pathsUrl = githubUrl(`/repos/${REPOSITORY}/traffic/popular/paths`); + const windowStart = `${startDate}T00:00:00.000Z`; + const endExclusive = parseDateOnly(endDate, '--end'); + endExclusive.setUTCDate(endExclusive.getUTCDate() + 1); + + const [repo, clones, views, topReferrers, topPaths, openIssueResult, windowIssueResult] = + await Promise.all([ + requestJson(fetchImplementation, repoUrl, { headers }), + requestJson(fetchImplementation, clonesUrl, { headers }), + requestJson(fetchImplementation, viewsUrl, { headers }), + requestJson(fetchImplementation, referrersUrl, { headers }), + requestJson(fetchImplementation, pathsUrl, { headers }), + fetchAllGithubIssues({ fetchImplementation, headers, state: 'open' }), + fetchAllGithubIssues({ + fetchImplementation, + headers, + state: 'all', + since: windowStart, + }), + ]); + + const isIssue = (item) => item && typeof item === 'object' && !item.pull_request; + const openIssues = openIssueResult.issues.filter(isIssue); + const issuesOpenedInWindow = windowIssueResult.issues.filter((item) => { + if (!isIssue(item)) return false; + const createdAt = new Date(item.created_at); + return createdAt >= new Date(windowStart) && createdAt < endExclusive; + }); + + return { + source: { + api: GITHUB_API, + repositoryUrl: `https://github.com/${REPOSITORY}`, + documentation: { + repository: 'https://docs.github.com/en/rest/repos/repos#get-a-repository', + traffic: 'https://docs.github.com/en/rest/metrics/traffic', + issues: 'https://docs.github.com/en/rest/issues/issues#list-repository-issues', + }, + trafficWindow: + 'GitHub-defined rolling 14-day window at capture time; it is independent of the explicit npm and issue window.', + requests: { + repository: repoUrl, + clones: clonesUrl, + views: viewsUrl, + topReferrers: referrersUrl, + topPaths: pathsUrl, + openIssues: openIssueResult.sourceUrl, + issuesOpenedInMeasurementWindow: windowIssueResult.sourceUrl, + }, + }, + repository: { + stars: requireNumber(repo.stargazers_count, 'GitHub stars'), + forks: requireNumber(repo.forks_count, 'GitHub forks'), + subscribers: requireNumber(repo.subscribers_count, 'GitHub subscribers'), + }, + traffic: { + clones: requireNumber(clones.count, 'GitHub clones'), + uniqueCloners: requireNumber(clones.uniques, 'GitHub unique cloners'), + cloneDays: requireArray(clones.clones, 'GitHub clone days'), + views: requireNumber(views.count, 'GitHub views'), + uniqueViewers: requireNumber(views.uniques, 'GitHub unique viewers'), + viewDays: requireArray(views.views, 'GitHub view days'), + topReferrers: requireArray(topReferrers, 'GitHub top referrers'), + topPaths: requireArray(topPaths, 'GitHub top paths'), + }, + issues: { + openAtCapture: openIssues.length, + openedInMeasurementWindow: issuesOpenedInWindow.length, + measurementWindow: { + startDate, + endDate, + timezone: 'UTC', + inclusivity: 'start and end dates inclusive', + }, + excludesPullRequests: true, + }, + }; +} + +async function captureMcpRegistry(fetchImplementation) { + const firstUrl = new URL(MCP_REGISTRY_API); + firstUrl.searchParams.set('search', MCP_REGISTRY_NAME); + firstUrl.searchParams.set('version', 'latest'); + firstUrl.searchParams.set('limit', '100'); + const queryUrl = firstUrl.href; + const exactRecords = []; + const visitedCursors = new Set(); + let cursor; + + do { + const url = new URL(queryUrl); + if (cursor) url.searchParams.set('cursor', cursor); + const response = await requestJson(fetchImplementation, url.href); + const servers = requireArray(response.servers ?? [], 'MCP Registry servers'); + + for (const record of servers) { + if (record?.server?.name !== MCP_REGISTRY_NAME) continue; + const official = record?._meta?.['io.modelcontextprotocol.registry/official'] ?? {}; + exactRecords.push({ + name: record.server.name, + version: record.server.version, + title: record.server.title ?? null, + status: official.status ?? null, + publishedAt: official.publishedAt ?? null, + isLatest: official.isLatest ?? null, + }); + } + + cursor = response.metadata?.nextCursor; + if (cursor && visitedCursors.has(cursor)) { + throw new Error('MCP Registry returned a repeated pagination cursor.'); + } + if (cursor) visitedCursors.add(cursor); + } while (cursor); + + return { + source: { + api: MCP_REGISTRY_API, + documentation: 'https://registry.modelcontextprotocol.io/docs', + queryUrl, + querySemantics: 'latest active records matching search, then exact server-name filtering', + }, + exactName: MCP_REGISTRY_NAME, + exactRecordCount: exactRecords.length, + records: exactRecords, + }; +} + +export async function captureAdoptionMetrics({ + startDate, + endDate, + capturedAt = new Date(), + githubToken, + fetch: fetchImplementation = globalThis.fetch, +}) { + const window = validateCompleteUtcWindow(startDate, endDate, new Date(capturedAt)); + if (typeof fetchImplementation !== 'function') { + throw new Error('A Fetch API implementation is required.'); + } + + const [npm, github, mcpRegistry] = await Promise.all([ + captureNpm({ startDate, endDate, fetchImplementation, window }), + captureGithub({ startDate, endDate, fetchImplementation, githubToken }), + captureMcpRegistry(fetchImplementation), + ]); + const mcpDownloads = npm.packages.find(({ name }) => name === '@buzzr/mcp').downloads; + + return { + schemaVersion: 1, + capturedAt: normalizeCapturedAt(capturedAt), + repository: REPOSITORY, + npm, + github, + mcpRegistry, + mcpAdoptionProxy: { + metric: 'npm package downloads', + package: '@buzzr/mcp', + downloads: mcpDownloads, + window, + uniqueUsers: false, + rationale: + 'Aggregate @buzzr/mcp downloads are a privacy-preserving MCP adoption proxy; they do not identify clients or unique users.', + }, + }; +} + +function resolveGithubToken(environment = process.env) { + const environmentToken = environment.GH_TOKEN ?? environment.GITHUB_TOKEN; + if (environmentToken?.trim()) return environmentToken.trim(); + + const result = spawnSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0 && result.stdout.trim()) return result.stdout.trim(); + + throw new Error( + 'GitHub traffic requires GH_TOKEN/GITHUB_TOKEN or a working `gh auth token` login.', + ); +} + +async function main() { + if (process.argv.slice(2).some((argument) => argument === '--help' || argument === '-h')) { + process.stdout.write( + 'Usage: npm run --silent capture:adoption -- --start YYYY-MM-DD --end YYYY-MM-DD\n' + + 'Both boundaries are inclusive complete UTC dates. JSON is written to stdout.\n', + ); + return; + } + + const { startDate, endDate } = parseArguments(process.argv.slice(2)); + validateCompleteUtcWindow(startDate, endDate); + const snapshot = await captureAdoptionMetrics({ + startDate, + endDate, + githubToken: resolveGithubToken(), + }); + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); +} + +const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (isMain) { + main().catch((error) => { + process.stderr.write(`Adoption capture failed: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/check-api-docs.mjs b/scripts/check-api-docs.mjs new file mode 100644 index 0000000..e702536 --- /dev/null +++ b/scripts/check-api-docs.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); + +const publicPackages = [ + ['bets-core', '@buzzr/bets-core'], + ['dfs-cli', '@buzzr/dfs-cli'], + ['dfs-engine', '@buzzr/dfs-engine'], + ['dfs-engine-test-vectors', '@buzzr/dfs-engine-test-vectors'], + ['dfs-provider-espn', '@buzzr/dfs-provider-espn'], + ['dfs-provider-sportradar', '@buzzr/dfs-provider-sportradar'], + ['dfs-react', '@buzzr/dfs-react'], + ['dfs-testkit', '@buzzr/dfs-testkit'], + ['entertainment-engine', '@buzzr/entertainment-engine'], + ['mcp', '@buzzr/mcp'], +]; + +async function readJson(path, label) { + const text = await readFile(resolve(root, path), 'utf8').catch((error) => { + if (error?.code === 'ENOENT') { + assert.fail(`${label} is missing: ${path}`); + } + throw error; + }); + + try { + return JSON.parse(text); + } catch (error) { + assert.fail(`${label} is not valid JSON: ${error.message}`); + } +} + +const [rootManifest, typedocConfig, docsWorkflow, apiIndex] = await Promise.all([ + readJson('package.json', 'Root package manifest'), + readJson('typedoc.json', 'Root TypeDoc configuration'), + readFile(resolve(root, '.github/workflows/docs.yml'), 'utf8'), + readFile(resolve(root, 'docs/api-reference.md'), 'utf8'), +]); + +assert.equal( + typedocConfig.entryPointStrategy, + 'packages', + 'Root TypeDoc must use package entry-point strategy.', +); + +const expectedEntryPoints = publicPackages.map(([directory]) => `packages/${directory}`).sort(); +const actualEntryPoints = (typedocConfig.entryPoints ?? []) + .map((entryPoint) => relative(root, resolve(root, entryPoint))) + .sort(); +assert.deepEqual( + actualEntryPoints, + expectedEntryPoints, + 'Root TypeDoc entry points must include every public package root exactly once.', +); + +const docsCommand = rootManifest.scripts?.docs ?? ''; +assert.match(docsCommand, /(?:^|\s)typedoc(?:\s|$)/, 'The root docs command must invoke TypeDoc.'); +assert.doesNotMatch( + docsCommand, + /(?:--workspace|packages\/dfs-engine)/, + 'The root docs command must not delegate to the dfs-engine workspace.', +); +assert.match( + docsCommand, + /node scripts\/check-api-docs\.mjs/, + 'The root docs command must validate the generated all-package output.', +); + +const outputDirectory = relative(root, resolve(root, typedocConfig.out ?? '')); +assert.ok(outputDirectory, 'Root TypeDoc must declare an output directory.'); +assert.match( + docsWorkflow, + new RegExp(`path:\\s*${outputDirectory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`), + `The Pages workflow must upload the root TypeDoc output (${outputDirectory}).`, +); + +assert.doesNotMatch( + apiIndex, + /TypeDoc[^\n]*@buzzr\/dfs-engine[^\n]*only/i, + 'docs/api-reference.md must not claim that TypeDoc covers only dfs-engine.', +); + +const apiJsonPath = typedocConfig.json; +assert.equal( + typeof apiJsonPath, + 'string', + 'Root TypeDoc must emit a machine-readable JSON model for coverage verification.', +); +const apiModel = await readJson(apiJsonPath, 'Generated TypeDoc API model'); +const documentedPackages = new Set( + (apiModel.children ?? []) + .filter((reflection) => reflection.kindString === 'Module' || reflection.kind === 2) + .map((reflection) => reflection.name), +); + +for (const [directory, packageName] of publicPackages) { + const manifest = await readJson(`packages/${directory}/package.json`, `${packageName} manifest`); + assert.equal(manifest.name, packageName, `${directory} must still publish as ${packageName}.`); + assert.ok( + documentedPackages.has(packageName), + `Generated TypeDoc API model must include the ${packageName} package root.`, + ); + assert.ok(apiIndex.includes(packageName), `docs/api-reference.md must index ${packageName}.`); + + const modulePath = resolve(root, outputDirectory, 'modules', `_buzzr_${directory}.html`); + const moduleHtml = await readFile(modulePath, 'utf8').catch((error) => { + if (error?.code === 'ENOENT') { + assert.fail(`Generated TypeDoc module page is missing for ${packageName}: ${relative(root, modulePath)}`); + } + throw error; + }); + assert.ok( + moduleHtml.includes(`

Module ${packageName}

`), + `Generated TypeDoc module page must identify ${packageName}.`, + ); +} + +const htmlPath = resolve(root, outputDirectory, 'index.html'); +const html = await readFile(htmlPath, 'utf8').catch((error) => { + if (error?.code === 'ENOENT') { + assert.fail(`Generated TypeDoc Pages entry point is missing: ${relative(root, htmlPath)}`); + } + throw error; +}); +assert.match(html, /Buzzr Sports Engine API/i, 'Generated Pages entry point must identify Buzzr APIs.'); + +console.log(`All-package TypeDoc contract passed for ${publicPackages.length} package roots.`); diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs new file mode 100644 index 0000000..6e38c78 --- /dev/null +++ b/scripts/check-doc-links.mjs @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { access, readFile, stat } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; + +const root = resolve(new URL('../', import.meta.url).pathname); +const checkExternal = process.argv.includes('--external'); +const trackedMarkdown = execFileSync('git', ['ls-files', '-z', '--', '*.md'], { + cwd: root, + encoding: 'utf8', +}) + .split('\0') + .filter(Boolean) + .filter((path) => !path.startsWith('docs/api/') && !/^packages\/[^/]+\/docs\//.test(path)); + +function linesOutsideCodeFences(content) { + const lines = []; + let fence = null; + for (const [index, line] of content.split(/\r?\n/).entries()) { + const marker = line.match(/^\s*(```+|~~~+)/)?.[1] ?? null; + if (marker) { + if (!fence) fence = marker[0]; + else if (marker[0] === fence) fence = null; + continue; + } + if (!fence) lines.push({ number: index + 1, text: line }); + } + return lines; +} + +function stripInlineCode(line) { + return line.replace(/`[^`]*`/g, ''); +} + +function normalizeDestination(raw) { + const value = raw.trim(); + if (value.startsWith('<')) { + const end = value.indexOf('>'); + return end === -1 ? value.slice(1) : value.slice(1, end); + } + return value.split(/\s+["']/u, 1)[0]; +} + +function markdownDestinations(line) { + return [...line.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)].map((match) => + normalizeDestination(match[1]), + ); +} + +function trimBareUrl(value) { + let url = value.replace(/[.,;:!?]+$/u, ''); + while (url.endsWith(')') && (url.match(/\(/g)?.length ?? 0) < (url.match(/\)/g)?.length ?? 0)) { + url = url.slice(0, -1); + } + return url; +} + +function bareUrls(line) { + return [...stripInlineCode(line).matchAll(/https?:\/\/[^\s<>"'`\]]+/g)].map((match) => + trimBareUrl(match[0]), + ); +} + +function githubSlug(value) { + return value + .toLowerCase() + .replace(/<[^>]+>/g, '') + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .trim() + .replace(/\s+/g, '-'); +} + +function documentAnchors(content) { + const anchors = new Set(); + const counts = new Map(); + for (const { text } of linesOutsideCodeFences(content)) { + const heading = text.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/)?.[1]; + if (heading) { + const base = githubSlug(heading); + const count = counts.get(base) ?? 0; + counts.set(base, count + 1); + anchors.add(count === 0 ? base : `${base}-${count}`); + } + for (const match of text.matchAll(/<(?:a\s+name|[^>]+\sid)=["']([^"']+)["']/gi)) { + anchors.add(match[1]); + } + } + return anchors; +} + +const documents = new Map( + await Promise.all( + trackedMarkdown.map(async (path) => [path, await readFile(resolve(root, path), 'utf8')]), + ), +); +const anchorCache = new Map(); +const localFailures = []; +const externalReferences = new Map(); +let localReferenceCount = 0; + +for (const [path, content] of documents) { + for (const { number, text } of linesOutsideCodeFences(content)) { + const destinations = markdownDestinations(text); + for (const href of destinations) { + if (!href || /^(?:mailto:|data:|javascript:)/i.test(href)) continue; + if (/^https?:/i.test(href)) { + const references = externalReferences.get(href) ?? []; + references.push(`${path}:${number}`); + externalReferences.set(href, references); + continue; + } + if (['link', 'url'].includes(href.toLowerCase()) || /[{}]/.test(href)) continue; + + localReferenceCount += 1; + const [encodedTarget, encodedFragment = ''] = href.split('#', 2); + let targetPath; + try { + targetPath = encodedTarget + ? resolve(dirname(resolve(root, path)), decodeURIComponent(encodedTarget)) + : resolve(root, path); + } catch { + localFailures.push(`${path}:${number} has malformed link ${href}`); + continue; + } + try { + await access(targetPath); + } catch { + localFailures.push(`${path}:${number} links to missing ${href}`); + continue; + } + + if (encodedFragment && ['.md', ''].includes(extname(targetPath).toLowerCase())) { + const relativeTarget = targetPath.slice(root.length + 1); + const targetContent = documents.get(relativeTarget); + if (targetContent !== undefined) { + const anchors = anchorCache.get(relativeTarget) ?? documentAnchors(targetContent); + anchorCache.set(relativeTarget, anchors); + const fragment = decodeURIComponent(encodedFragment).toLowerCase(); + if (!anchors.has(fragment)) { + localFailures.push(`${path}:${number} links to missing anchor ${href}`); + } + } + } + } + + if (checkExternal) { + for (const url of bareUrls(text)) { + const references = externalReferences.get(url) ?? []; + references.push(`${path}:${number}`); + externalReferences.set(url, references); + } + } + } +} + +assert.deepEqual( + localFailures, + [], + `Broken local documentation links:\n${localFailures.join('\n')}`, +); + +function localPagesTarget(url) { + const parsed = new URL(url); + if ( + parsed.origin !== 'https://buzzr-app.github.io' || + !parsed.pathname.startsWith('/dfs-engine/') + ) { + return null; + } + const relative = parsed.pathname.slice('/dfs-engine/'.length) || 'index.html'; + return resolve(root, 'docs/api', relative.endsWith('/') ? `${relative}index.html` : relative); +} + +async function fetchReachability(url) { + const localTarget = localPagesTarget(url); + if (localTarget) { + await access(localTarget); + return; + } + + let lastError; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const response = await fetch(url, { + redirect: 'follow', + headers: { + Range: 'bytes=0-0', + 'User-Agent': 'Buzzr-doc-link-check/1.0 (+https://github.com/Buzzr-app/dfs-engine)', + }, + signal: controller.signal, + }); + await response.body?.cancel(); + if (response.status === 404 || response.status === 410) { + throw new Error(`HTTP ${response.status}`); + } + if (response.status >= 500) { + throw new Error(`HTTP ${response.status}`); + } + return; + } catch (error) { + lastError = error; + } finally { + clearTimeout(timeout); + } + } + throw lastError; +} + +if (checkExternal) { + const queue = [...externalReferences.keys()].sort(); + const failures = []; + let cursor = 0; + await Promise.all( + Array.from({ length: Math.min(8, queue.length) }, async () => { + while (cursor < queue.length) { + const url = queue[cursor]; + cursor += 1; + try { + await fetchReachability(url); + } catch (error) { + failures.push( + `${url} (${externalReferences.get(url).join(', ')}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + }), + ); + assert.deepEqual(failures, [], `Broken external documentation links:\n${failures.join('\n')}`); +} + +console.log( + `Documentation links passed: ${trackedMarkdown.length} files, ${localReferenceCount} local references${ + checkExternal ? `, ${externalReferences.size} external URLs` : '' + }.`, +); diff --git a/scripts/check-mcp-client-skill-contracts.mjs b/scripts/check-mcp-client-skill-contracts.mjs new file mode 100644 index 0000000..bfc4c0b --- /dev/null +++ b/scripts/check-mcp-client-skill-contracts.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const read = (path) => readFile(`${root}${path}`, 'utf8'); + +const [readme, skill, toolReference, packageManifestText] = await Promise.all([ + read('packages/mcp/README.md'), + read('skills/buzzr-sports-engine/SKILL.md'), + read('skills/buzzr-sports-engine/references/mcp-tools.md'), + read('package.json'), +]); +const packageManifest = JSON.parse(packageManifestText); + +function requirePattern(text, pattern, message) { + assert.match(text, pattern, message); +} + +const clientContracts = [ + ['Claude Desktop', /### Claude Desktop/, /claude_desktop_config\.json/], + [ + 'Claude Code', + /### Claude Code/, + /claude mcp add --transport stdio buzzr -- npx -y @buzzr\/mcp@5\.1\.0/, + ], + ['Cursor', /### Cursor/, /\.cursor\/mcp\.json/], + ['Codex', /### Codex/, /\[mcp_servers\.buzzr\]/], + ['generic stdio MCP client', /### Generic stdio MCP client/i, /"transport": "stdio"/], +]; + +for (const [client, heading, setup] of clientContracts) { + requirePattern(readme, heading, `@buzzr/mcp README must have a separate ${client} setup section`); + requirePattern(readme, setup, `@buzzr/mcp README must provide copy-paste ${client} setup`); +} + +for (const discoveryTerm of [ + /`initialize`/, + /`serverInfo\.name`/, + /`serverInfo\.version`/, + /`capabilities\.tools`/, + /`notifications\/initialized`/, + /`tools\/list`/, +]) { + requirePattern(readme, discoveryTerm, 'README must document MCP initialization and discovery'); +} + +requirePattern( + readme, + /packages\/mcp\/examples\/mcp-calls\.json|examples\/mcp-calls\.json/, + 'README must link the machine-readable MCP call examples', +); +requirePattern(readme, /1[–-]50 entries/i, 'README must state the 50-entry batch limit'); +requirePattern(readme, /600 total legs/i, 'README must state the 600-leg aggregate limit'); + +for (const troubleshootingTerm of [ + /Node(?:\.js)? 22\+/i, + /npm cache/i, + /PATH/, + /Windows/i, + /macOS/i, + /Linux/i, + /restart[^\n]*(?:client|Claude|Cursor|Codex)/i, +]) { + requirePattern( + readme, + troubleshootingTerm, + 'README must cover Node, npx cache, PATH, platform, and restart troubleshooting', + ); +} + +const toolNames = [ + 'grade_dfs_entry', + 'grade_dfs_entries', + 'validate_dfs_entry', + 'list_book_policies', + 'fair_line', + 'closing_line_value', + 'parlay_value', + 'kelly_stake', + 'summarize_bet_history', + 'predict_game_buzz', + 'rank_games', +]; +for (const toolName of toolNames) { + requirePattern(readme, new RegExp(`\\\`${toolName}\\\``), `README must list ${toolName}`); +} + +requirePattern(skill, /2[–-]50 entries/i, 'SKILL.md must state the 50-entry batch limit'); +requirePattern(skill, /600 total legs/i, 'SKILL.md must state the 600-leg aggregate limit'); +requirePattern( + toolReference, + /1[–-]50 entries/i, + 'The MCP tool reference must state the 50-entry batch limit', +); +requirePattern( + toolReference, + /600 total legs/i, + 'The MCP tool reference must state the 600-leg aggregate limit', +); + +assert.equal( + packageManifest.devDependencies?.skills, + '1.5.17', + 'The one-command local skill installer must be pinned exactly to skills@1.5.17.', +); +assert.equal( + packageManifest.scripts?.['check:mcp:examples'], + 'node scripts/validate-mcp-examples.mjs', + 'package.json must expose the MCP examples validator', +); +assert.equal( + packageManifest.scripts?.['check:skill'], + 'node scripts/validate-buzzr-skill.mjs', + 'package.json must expose the repository skill validator', +); +for (const requiredCheck of [ + 'npm run check:mcp:client-skill', + 'npm run check:mcp:examples', + 'npm run check:skill', +]) { + assert.ok( + packageManifest.scripts?.verify?.includes(requiredCheck), + `verify must run ${requiredCheck}`, + ); +} + +for (const path of [ + 'packages/mcp/examples/mcp-calls.json', + 'scripts/validate-mcp-examples.mjs', + 'scripts/validate-buzzr-skill.mjs', + 'scripts/vendor/openai-skill-creator/quick_validate.py', +]) { + await access(`${root}${path}`); +} + +const examples = JSON.parse(await read('packages/mcp/examples/mcp-calls.json')); +assert.equal(examples.schemaVersion, '1'); +assert.equal(examples.transport, 'stdio'); +assert.equal(examples.discovery?.initialize?.request?.method, 'initialize'); +assert.equal(examples.discovery?.initialized?.method, 'notifications/initialized'); +assert.equal(examples.discovery?.toolsList?.request?.method, 'tools/list'); +assert.deepEqual(examples.discovery?.toolsList?.expect?.toolNames, toolNames); + +const workflowNames = examples.workflows?.[0]?.calls?.map((call) => call.request?.params?.name); +assert.deepEqual( + workflowNames, + ['list_book_policies', 'validate_dfs_entry', 'grade_dfs_entry'], + 'The machine-readable workflow must follow the skill safety order.', +); + +process.stdout.write( + 'MCP client setup, discovery, examples, limits, troubleshooting, and repository skill contracts are complete.\n', +); diff --git a/scripts/check-mcp-registry-metadata.mjs b/scripts/check-mcp-registry-metadata.mjs new file mode 100644 index 0000000..5b6196b --- /dev/null +++ b/scripts/check-mcp-registry-metadata.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; + +const [rootManifest, mcpManifest, server] = await Promise.all( + ['package.json', 'packages/mcp/package.json', 'server.json'].map(async (path) => + JSON.parse(await readFile(path, 'utf8')), + ), +); + +const schema = 'https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json'; +const registryName = 'io.github.Buzzr-app/dfs-engine'; +const repositoryUrl = 'https://github.com/Buzzr-app/dfs-engine'; + +const schemaResponse = await fetch(schema, { signal: AbortSignal.timeout(15_000) }); +assert.equal( + schemaResponse.ok, + true, + `Could not fetch MCP Registry schema: ${schemaResponse.status}`, +); +const registrySchema = await schemaResponse.json(); +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const validate = ajv.compile(registrySchema); +assert.equal( + validate(server), + true, + `server.json does not match the MCP Registry schema: ${ajv.errorsText(validate.errors)}`, +); + +assert.equal(server.$schema, schema); +assert.equal(mcpManifest.mcpName, registryName); +assert.equal(server.name, registryName); +assert.equal(server.title, 'Buzzr Sports Engine'); +assert.equal(typeof server.description, 'string'); +assert(server.description.length > 0 && server.description.length <= 100); +assert.equal(server.version, mcpManifest.version); +assert.equal(server.version, rootManifest.version); +assert.deepEqual(server.repository, { + url: repositoryUrl, + source: 'github', + id: '1234984143', + subfolder: 'packages/mcp', +}); +assert.equal(server.websiteUrl, 'https://buzzr-app.github.io/dfs-engine/'); +assert.equal(server.packages.length, 1); +assert.deepEqual(server.packages[0], { + registryType: 'npm', + identifier: '@buzzr/mcp', + version: mcpManifest.version, + transport: { type: 'stdio' }, +}); +assert(!JSON.stringify(server).match(/environmentVariables|secret|token|api.?key/i)); + +console.log(`Verified MCP Registry metadata for ${registryName}@${server.version}.`); diff --git a/scripts/check-public-docs.mjs b/scripts/check-public-docs.mjs new file mode 100644 index 0000000..d8ffd4e --- /dev/null +++ b/scripts/check-public-docs.mjs @@ -0,0 +1,495 @@ +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); + +const files = { + agents: 'AGENTS.md', + root: 'README.md', + llms: 'llms.txt', + architecture: 'docs/architecture.md', + security: 'docs/security-and-privacy.md', + versioning: 'docs/versioning-and-support.md', + apiIndex: 'docs/api-reference.md', + bets: 'packages/bets-core/README.md', + engine: 'packages/dfs-engine/README.md', + mcp: 'packages/mcp/README.md', + cli: 'packages/dfs-cli/README.md', + vectors: 'packages/dfs-engine-test-vectors/README.md', + testkit: 'packages/dfs-testkit/README.md', + entertainment: 'packages/entertainment-engine/README.md', + react: 'packages/dfs-react/README.md', + espnProvider: 'packages/dfs-provider-espn/README.md', + sportradarProvider: 'packages/dfs-provider-sportradar/README.md', + vectorManifest: 'packages/dfs-engine-test-vectors/package.json', + skill: 'skills/buzzr-sports-engine/SKILL.md', + skillTools: 'skills/buzzr-sports-engine/references/mcp-tools.md', + engineChangelog: 'packages/dfs-engine/CHANGELOG.md', + mcpChangelog: 'packages/mcp/CHANGELOG.md', + cliChangelog: 'packages/dfs-cli/CHANGELOG.md', + vectorsChangelog: 'packages/dfs-engine-test-vectors/CHANGELOG.md', + testkitChangelog: 'packages/dfs-testkit/CHANGELOG.md', + baseline: 'docs/launch/adoption-baseline-2026-07-16.md', + checklist: 'docs/launch/launch-checklist.md', + awesomeLists: 'docs/launch/awesome-lists.md', + devto: 'docs/launch/devto-article.md', + reddit: 'docs/launch/reddit.md', + showHn: 'docs/launch/show-hn.md', + twitter: 'docs/launch/twitter-thread.md', +}; + +const docs = Object.fromEntries( + await Promise.all( + Object.entries(files).map(async ([key, path]) => [ + key, + await readFile(`${root}${path}`, 'utf8').catch((error) => { + if (error?.code === 'ENOENT') return ''; + throw error; + }), + ]), + ), +); + +const publicKeys = Object.keys(docs); +const publicText = publicKeys.map((key) => docs[key]).join('\n'); + +const staleClaims = [ + [/\b8 tools?\b/i, 'the retired eight-tool catalog'], + [/book[- ]accurate/i, 'book-accurate operator behavior'], + [ + /mirrors?\s+PrizePicks(?:\s*\/\s*|\s+and\s+)Underdog(?:\s+settlement)?\s+rules/i, + 'mirrored operator rules', + ], + [ + /PrizePicks and Underdog (?:ship as|are still) stable built-ins/i, + 'stable built-in operator profiles', + ], + [/baseline:\s*~?24 downloads\/week/i, 'the superseded 24-download baseline'], + [/\|\s*~?24\s*\|/i, 'the superseded 24-download metric row'], + [ + /grades identically to Buzzr(?:'s)? (?:production pipeline|grading)/i, + 'production-conformance vectors', + ], + [/prove(?:s)? your integration grades identically to Buzzr/i, 'production-conformance vectors'], + [/passing conformance test/i, 'an overclaimed conformance test'], + [ + /published (?:golden )?(?:test )?vectors? for conformance testing/i, + 'official-sounding conformance vectors', + ], + [/published conformance vectors/i, 'official-sounding conformance vectors'], + [/Everything is pure functions: no I\/O/i, 'pure-function behavior for boundary packages'], + [/No package may add a runtime dependency outside/i, 'a zero-dependency rule for every package'], + [/Golden vectors are conformance law/i, 'regression vectors as conformance law'], + [/all packages currently release in lockstep/i, 'an unverified lockstep-release rule'], + [/JSON-RPC `-32602`/i, 'SDK-amplified tool argument errors'], + [/1[–-]25 entries/i, 'the retired 25-entry MCP batch limit'], + [/300 total legs/i, 'the retired 300-leg MCP aggregate limit'], + [/grades identically to Buzzr/i, 'production-conformance vectors'], + [/canonical reference fixtures/i, 'canonical operator fixtures'], + [//i, 'an unresolved package-version placeholder'], + [/\bvNext\b/i, 'an unresolved release placeholder'], + [ + /zero-dependency npm packages \(@buzzr\/\*\)/i, + 'zero runtime dependencies across every public package', + ], +]; + +for (const [pattern, label] of staleClaims) { + assert.doesNotMatch(publicText, pattern, `Public docs still claim ${label}.`); +} + +function requireText(key, expected, reason) { + assert.ok( + docs[key].includes(expected), + `${files[key]} must ${reason}; missing ${JSON.stringify(expected)}.`, + ); +} + +function requirePattern(key, pattern, reason) { + assert.match(docs[key], pattern, `${files[key]} must ${reason}.`); +} + +const packageReadmes = [ + { + key: 'engine', + name: '@buzzr/dfs-engine', + purpose: /DFS prop grading/i, + install: 'npm install @buzzr/dfs-engine', + typedoc: '_buzzr_dfs-engine.html', + }, + { + key: 'bets', + name: '@buzzr/bets-core', + purpose: /sportsbook normalization/i, + install: 'npm install @buzzr/bets-core', + typedoc: '_buzzr_bets-core.html', + }, + { + key: 'entertainment', + name: '@buzzr/entertainment-engine', + purpose: /entertainment scoring/i, + install: 'npm install @buzzr/entertainment-engine', + typedoc: '_buzzr_entertainment-engine.html', + }, + { + key: 'mcp', + name: '@buzzr/mcp', + purpose: /MCP server/i, + install: 'npx -y @buzzr/mcp@5.1.0', + typedoc: '_buzzr_mcp.html', + }, + { + key: 'cli', + name: '@buzzr/dfs-cli', + purpose: /command-line wrapper/i, + install: 'npm install -g @buzzr/dfs-cli', + typedoc: '_buzzr_dfs-cli.html', + }, + { + key: 'react', + name: '@buzzr/dfs-react', + purpose: /render-ready view-model/i, + install: 'npm install @buzzr/dfs-react @buzzr/dfs-engine', + typedoc: '_buzzr_dfs-react.html', + }, + { + key: 'testkit', + name: '@buzzr/dfs-testkit', + purpose: /Fixture builders/i, + install: 'npm install --save-dev @buzzr/dfs-testkit @buzzr/dfs-engine', + typedoc: '_buzzr_dfs-testkit.html', + }, + { + key: 'espnProvider', + name: '@buzzr/dfs-provider-espn', + purpose: /ESPN-shaped gamelog data/i, + install: 'npm install @buzzr/dfs-provider-espn @buzzr/dfs-engine', + typedoc: '_buzzr_dfs-provider-espn.html', + }, + { + key: 'sportradarProvider', + name: '@buzzr/dfs-provider-sportradar', + purpose: /Sportradar basketball statlines/i, + install: 'npm install @buzzr/dfs-provider-sportradar @buzzr/dfs-engine', + typedoc: '_buzzr_dfs-provider-sportradar.html', + }, + { + key: 'vectors', + name: '@buzzr/dfs-engine-test-vectors', + purpose: /engine regression fixtures/i, + install: + 'npm install --save-dev @buzzr/dfs-engine-test-vectors@5.1.0 @buzzr/dfs-engine@5.1.0', + typedoc: '_buzzr_dfs-engine-test-vectors.html', + }, +]; + +for (const spec of packageReadmes) { + const manifestPath = `${root}packages/${spec.name.slice('@buzzr/'.length)}/package.json`; + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const dependencies = Object.keys(manifest.dependencies ?? {}); + + assert.equal(manifest.name, spec.name, `${manifestPath} must retain its public package name.`); + assert.equal(manifest.engines?.node, '>=22', `${manifestPath} must retain the Node.js >= 22 floor.`); + assert.equal(manifest.license, 'MIT', `${manifestPath} must retain the MIT license.`); + requireText(spec.key, `# ${spec.name}`, 'use the exact public package name as its heading'); + requirePattern(spec.key, spec.purpose, 'state its package-specific purpose'); + requireText(spec.key, spec.install, 'show its supported install or execution command'); + requirePattern(spec.key, /Node\.js >= 22/i, 'state the supported Node.js floor'); + requireText( + spec.key, + `Import the supported API from \`${spec.name}\``, + 'identify the supported package-root API', + ); + requireText( + spec.key, + 'Deep `src/*` and `dist/*` imports are unsupported.', + 'reject unsupported deep imports', + ); + requireText(spec.key, '../../docs/api-reference.md', 'link the all-package API index'); + requireText( + spec.key, + `https://buzzr-app.github.io/dfs-engine/modules/${spec.typedoc}`, + 'link its generated root-export reference', + ); + requireText( + spec.key, + 'https://github.com/Buzzr-app/dfs-engine/issues', + 'link the shared support tracker', + ); + requireText(spec.key, '../../SECURITY.md', 'link the private security-reporting policy'); + requireText(spec.key, '../../docs/versioning-and-support.md', 'link the shared support policy'); + requireText(spec.key, '../../LICENSE', 'link the repository MIT license'); + + if (/\bzero (?:external )?runtime dependencies\b/i.test(docs[spec.key])) { + assert.deepEqual( + dependencies, + [], + `${files[spec.key]} claims zero runtime dependencies but ${manifestPath} declares ${dependencies.join(', ')}.`, + ); + } +} + +for (const key of ['root', 'llms', 'mcp']) { + requirePattern(key, /\b11 tools\b/i, 'state the current MCP tool count'); +} +requirePattern('agents', /\b11 tools\b/i, 'state the current MCP tool count'); + +const toolNames = [ + 'grade_dfs_entry', + 'grade_dfs_entries', + 'validate_dfs_entry', + 'list_book_policies', + 'fair_line', + 'closing_line_value', + 'parlay_value', + 'kelly_stake', + 'summarize_bet_history', + 'predict_game_buzz', + 'rank_games', +]; + +for (const toolName of toolNames) { + requireText('mcp', `\`${toolName}\``, `list the ${toolName} tool`); +} + +for (const key of ['root', 'engine', 'mcp', 'cli']) { + requirePattern( + key, + /PrizePicks[^\n]*(?:experimental[^\n]*partial|partial[^\n]*experimental)/i, + 'label the PrizePicks profile experimental and partially verified', + ); + requirePattern( + key, + /Underdog[^\n]*(?:experimental[^\n]*unverified|unverified[^\n]*experimental)/i, + 'label the Underdog profile experimental and unverified', + ); + requirePattern( + key, + /displayed (?:lineup|entry|slip) terms? (?:are|remain|is) authoritative/i, + 'make the displayed terms authoritative', + ); +} + +for (const key of ['engine', 'mcp']) { + requireText( + key, + 'https://www.prizepicks.com/help-center/payouts', + 'cite the reviewed PrizePicks payouts source', + ); + requireText( + key, + 'https://www.prizepicks.com/help-center/potential-outcomes', + 'cite the reviewed PrizePicks standard outcomes source', + ); + requireText( + key, + 'https://www.prizepicks.com/help-center/dnps-reboots-and-ties', + 'cite the reviewed PrizePicks DNP, reboot, and tie source', + ); + requirePattern( + key, + /2-pick Power[^\n]*DNP[^\n]*refund/i, + 'state the reviewed two-pick Power DNP refund behavior', + ); + requireText(key, 'https://legal.underdogsports.com/', 'cite the canonical Underdog legal source'); +} + +requirePattern('mcp', /executable:\s*false/i, 'explain that draft policies are non-executable'); +requirePattern( + 'mcp', + /(?:reject|cannot|never)[^\n]*draft/i, + 'explain that grading does not execute drafts', +); +requirePattern( + 'mcp', + /(?:invalid tool arguments[\s\S]{0,180}`invalid_input`[\s\S]{0,180}(?:transport|MCP client)|(?:transport|MCP client)[\s\S]{0,180}`invalid_input`)/i, + 'document bounded transport validation failures', +); +requirePattern('mcp', /1[–-]50 entries/i, 'state the current batch entry bound'); +requirePattern('mcp', /600 total legs/i, 'state the current aggregate leg bound'); + +const mobileSnapshot = + 'The Buzzr mobile app’s `release/ios-2.0.0` branch vendors `@buzzr/bets-core`, `@buzzr/dfs-engine`, and `@buzzr/entertainment-engine` as local 5.0.0 tarballs and imports all three.'; +requireText('root', mobileSnapshot, 'state the exact verified mobile integration snapshot'); +requireText('llms', mobileSnapshot, 'carry the exact verified mobile integration snapshot'); +for (const key of ['root', 'llms']) { + requireText( + key, + 'https://apps.apple.com/us/app/buzzr-sports/id6760628256', + 'link the live Buzzr App Store listing without changing the verified app snapshot', + ); + requirePattern( + key, + /not automatically (?:updated|upgraded)[^\n]*5\.1\.0/i, + 'separate the app snapshot from public release 5.1.0', + ); + requireText( + key, + 'skills/buzzr-sports-engine/SKILL.md', + 'link to the repository-owned Buzzr skill', + ); + requireText( + key, + 'npx skills add https://github.com/Buzzr-app/dfs-engine --skill buzzr-sports-engine', + 'show the Buzzr skill install command', + ); + requireText(key, 'packages/mcp/README.md', 'link to MCP client configuration'); +} + +for (const key of ['root', 'llms', 'vectors']) { + requirePattern( + key, + /engine regression fixtures/i, + 'describe test vectors as engine regression fixtures', + ); + requirePattern( + key, + /not official operator conformance/i, + 'disclaim official operator conformance', + ); +} +requirePattern('testkit', /engine regression fixtures/i, 'route consumers to regression fixtures'); +requirePattern( + 'testkit', + /not official operator conformance/i, + 'disclaim official operator conformance', +); +requirePattern( + 'vectorManifest', + /engine regression fixtures/i, + 'describe the package as engine regression fixtures', +); +requirePattern('skill', /2[–-]50 entries/i, 'state the current batch entry bound'); +requirePattern('skill', /600 total legs/i, 'state the current aggregate leg bound'); +requirePattern('skillTools', /1[–-]50 entries/i, 'state the current batch entry bound'); +requirePattern('skillTools', /600 total legs/i, 'state the current aggregate leg bound'); + +requireText('baseline', '2026-07-09 through 2026-07-15', 'preserve the measured baseline window'); +requirePattern( + 'baseline', + /\b191\b[^\n]*package downloads/i, + 'preserve the measured family baseline', +); +requirePattern( + 'checklist', + /baseline[^\n]*191[^\n]*2026-07-09[^\n]*2026-07-15/i, + 'use the measured launch baseline', +); + +for (const key of ['checklist', 'awesomeLists', 'devto', 'reddit', 'showHn', 'twitter']) { + requirePattern( + key, + /\b(?:draft|drafts|prepared text only)\b/i, + 'identify the material as a draft', + ); + requirePattern(key, /\bmanual(?:ly)?\b/i, 'require manual publication'); +} + +for (const [key, version] of Object.entries({ + engineChangelog: '5.1.0', + mcpChangelog: '5.1.0', + cliChangelog: '5.0.1', + vectorsChangelog: '5.1.0', + testkitChangelog: '5.0.1', +})) { + requirePattern(key, new RegExp(`^## ${version.replaceAll('.', '\\.')}$`, 'm'), `record ${version}`); + assert.doesNotMatch(docs[key], /^## Unreleased/m, `${files[key]} must not retain release-ready work as unreleased.`); +} + +for (const key of ['root', 'agents']) { + requireText(key, 'docs/architecture.md', 'link the architecture and data-flow reference'); + requireText(key, 'docs/security-and-privacy.md', 'link the threat-model and privacy reference'); + requireText(key, 'docs/versioning-and-support.md', 'link the versioning and support policy'); + requireText(key, 'docs/api-reference.md', 'link the all-package API index'); +} + +requirePattern( + 'agents', + /core engines[^\n]*zero external runtime dependencies/i, + 'scope the zero-dependency invariant to core engines', +); +requirePattern('agents', /MCP[^\n]*(?:SDK|Zod)/i, 'record the MCP runtime dependency exception'); +requirePattern('agents', /engine regression fixtures/i, 'treat vectors as regression fixtures'); +requirePattern( + 'agents', + /not official operator conformance/i, + 'reject operator-conformance overclaims', +); +requireText('agents', 'node scripts/check-public-docs.mjs', 'include the executable docs gate'); + +requirePattern( + 'architecture', + /^# Architecture and data flow$/m, + 'define the architecture reference', +); +requirePattern('architecture', /core engines/i, 'describe the pure core layer'); +requirePattern('architecture', /boundary (?:packages|layer)/i, 'describe I/O boundaries'); +requirePattern('architecture', /MCP/i, 'describe MCP placement'); +requirePattern('architecture', /StatProvider/i, 'describe provider injection'); + +requirePattern( + 'security', + /^# Security, privacy, and threat model$/m, + 'define the threat-model reference', +); +for (const term of [ + 'trust boundaries', + 'untrusted input', + 'private user data', + 'stdout', + 'stderr', +]) { + requirePattern('security', new RegExp(term, 'i'), `cover ${term}`); +} +requirePattern('security', /does not fetch live odds/i, 'state the MCP network/data non-goal'); +requireText('security', 'SECURITY.md', 'link the vulnerability-reporting policy'); + +requirePattern( + 'versioning', + /^# Versioning, compatibility, and support$/m, + 'define the release policy', +); +requirePattern('versioning', /independent package/i, 'document smallest-scope package releases'); +requirePattern('versioning', /Node(?:\.js)? (?:>=|≥) 22/i, 'state the supported Node floor'); +requirePattern('versioning', /changesets/i, 'document release planning'); +requirePattern('versioning', /migration/i, 'document migration expectations'); +requirePattern('versioning', /release\/ios-2\.0\.0/i, 'separate the mobile app snapshot'); + +const packageNames = [ + '@buzzr/dfs-engine', + '@buzzr/bets-core', + '@buzzr/entertainment-engine', + '@buzzr/mcp', + '@buzzr/dfs-cli', + '@buzzr/dfs-react', + '@buzzr/dfs-testkit', + '@buzzr/dfs-provider-espn', + '@buzzr/dfs-provider-sportradar', + '@buzzr/dfs-engine-test-vectors', +]; +for (const packageName of packageNames) { + requireText('apiIndex', packageName, `index ${packageName}`); +} +requirePattern( + 'apiIndex', + /TypeDoc[^\n]*all ten public packages/i, + 'state that generated references cover all ten public packages', +); + +for (const [key, content] of Object.entries(docs)) { + for (const match of content.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) { + const href = match[1].trim(); + if (/^(?:https?:|mailto:|#)/i.test(href)) continue; + if (['link', 'url'].includes(href.toLowerCase())) continue; + + const withoutAnchor = href.split('#', 1)[0].replace(/^<|>$/g, ''); + const target = resolve(dirname(`${root}${files[key]}`), decodeURIComponent(withoutAnchor)); + await access(target).catch(() => { + assert.fail(`${files[key]} contains a missing local link: ${href}`); + }); + } +} + +console.log(`Public docs contract passed for ${publicKeys.length} files.`); diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..ec25294 --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const expectedVersion = process.env.EXPECTED_RELEASE_VERSION?.trim(); +assert(expectedVersion, 'EXPECTED_RELEASE_VERSION is required'); +assert.match( + expectedVersion, + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, + 'EXPECTED_RELEASE_VERSION must be an exact semantic version', +); + +async function manifest(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +const [root, release, server] = await Promise.all([ + manifest('package.json'), + manifest('release-manifest.json'), + manifest('server.json'), +]); + +assert.equal(root.version, expectedVersion, 'root version must equal the reviewed release'); +assert.equal(release.releaseVersion, expectedVersion, 'release manifest version must be reviewed'); +assert.equal(release.tag, `v${expectedVersion}`, 'release tag must match the reviewed version'); +assert.deepEqual(release.registry, { + npm: 'https://registry.npmjs.org/', + mcpApi: 'https://registry.modelcontextprotocol.io/v0.1', + mcpServerName: 'io.github.Buzzr-app/dfs-engine', + mcpPackage: '@buzzr/mcp', + repository: 'https://github.com/Buzzr-app/dfs-engine', + workflowPath: '.github/workflows/release.yml', + ref: 'refs/heads/main', +}); +assert(Array.isArray(release.packages), 'release manifest packages must be an array'); +assert.equal(release.packages.length, 10, 'release manifest must cover all ten public packages'); + +const entriesByName = new Map(); +const entriesByPath = new Map(); +for (const entry of release.packages) { + assert.equal(typeof entry.name, 'string'); + assert.match(entry.name, /^@buzzr\/[a-z0-9-]+$/); + assert.equal(typeof entry.path, 'string'); + assert.match(entry.path, /^packages\/[a-z0-9-]+$/); + assert.match(entry.version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/); + assert.equal(typeof entry.publish, 'boolean'); + assert.equal(typeof entry.internalDependencies, 'object'); + assert.equal(entriesByName.has(entry.name), false, `duplicate release package ${entry.name}`); + assert.equal(entriesByPath.has(entry.path), false, `duplicate release path ${entry.path}`); + entriesByName.set(entry.name, entry); + entriesByPath.set(entry.path, entry); +} + +const expectedPublishes = [ + '@buzzr/dfs-cli', + '@buzzr/dfs-engine', + '@buzzr/dfs-engine-test-vectors', + '@buzzr/dfs-testkit', + '@buzzr/mcp', +]; +assert.deepEqual( + release.packages + .filter((entry) => entry.publish) + .map((entry) => entry.name) + .sort(), + expectedPublishes, + 'release manifest must authorize exactly the five reviewed package publishes', +); + +const workspaceDirectories = (await readdir('packages', { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => `packages/${entry.name}`) + .sort(); +assert.deepEqual( + workspaceDirectories, + [...entriesByPath.keys()].sort(), + 'release manifest must match the complete workspace directory set', +); + +const packageManifests = new Map(); +for (const entry of release.packages) { + const packageManifest = await manifest(join(entry.path, 'package.json')); + packageManifests.set(entry.name, packageManifest); + assert.equal(packageManifest.name, entry.name, `${entry.path} package name drifted`); + assert.equal(packageManifest.version, entry.version, `${entry.name} version drifted`); + assert.notEqual(packageManifest.private, true, `${entry.name} must remain public`); + assert.equal( + packageManifest.publishConfig?.access, + 'public', + `${entry.name} must publish publicly`, + ); + + const internalDependencies = Object.fromEntries( + Object.entries(packageManifest.dependencies ?? {}).filter(([name]) => + name.startsWith('@buzzr/'), + ), + ); + assert.deepEqual( + internalDependencies, + entry.internalDependencies, + `${entry.name} internal dependency pins or ranges drifted`, + ); + for (const section of ['devDependencies', 'optionalDependencies', 'peerDependencies']) { + const unexpected = Object.keys(packageManifest[section] ?? {}).filter((name) => + name.startsWith('@buzzr/'), + ); + assert.deepEqual(unexpected, [], `${entry.name} has unreviewed internal ${section}`); + } +} + +assert.equal(server.version, expectedVersion, 'server version must equal the reviewed release'); +assert.equal(server.name, release.registry.mcpServerName); +assert.deepEqual(server.repository, { + url: release.registry.repository, + source: 'github', + id: '1234984143', + subfolder: 'packages/mcp', +}); +assert.equal(server.packages?.length, 1, 'server must expose exactly one npm package'); +assert.deepEqual(server.packages[0], { + registryType: 'npm', + identifier: release.registry.mcpPackage, + version: entriesByName.get(release.registry.mcpPackage)?.version, + transport: { type: 'stdio' }, +}); +const mcp = packageManifests.get(release.registry.mcpPackage); +assert(mcp, 'release manifest must include the MCP package'); +assert.equal(mcp.mcpName, server.name, 'npm and MCP Registry package names must remain linked'); +assert.equal( + mcp.version, + server.version, + 'npm and MCP Registry package versions must remain linked', +); + +const remainingChangesets = (await readdir('.changeset')).filter( + (name) => name.endsWith('.md') && name !== 'README.md', +); +assert.deepEqual(remainingChangesets, [], 'versioned release must not retain pending changesets'); + +console.log( + `Verified ${release.packages.length} exact package versions, ${expectedPublishes.length} publishes, internal dependency ranges, registry linkage, and zero pending changesets for ${expectedVersion}.`, +); diff --git a/scripts/check-release-workflows.mjs b/scripts/check-release-workflows.mjs new file mode 100644 index 0000000..34f7013 --- /dev/null +++ b/scripts/check-release-workflows.mjs @@ -0,0 +1,580 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const workflowPaths = [ + '.github/workflows/ci.yml', + '.github/workflows/docs.yml', + '.github/workflows/prove-mcp-published.yml', + '.github/workflows/release.yml', +]; + +const workflows = await Promise.all( + workflowPaths.map(async (path) => ({ path, body: await readFile(path, 'utf8') })), +); + +for (const { path, body } of workflows) { + const mutableActionRefs = [...body.matchAll(/uses:\s+[^\s#]+@(v\d+|main|master)\b/g)].map( + (match) => match[0], + ); + assert.deepEqual(mutableActionRefs, [], `${path} must pin every action to a commit SHA`); + + for (const match of body.matchAll(/uses:\s+actions\/checkout@[0-9a-f]{40}/g)) { + const step = body.slice(match.index, match.index + 240); + assert.match( + step, + /persist-credentials:\s+false/, + `${path} checkout steps must disable persisted credentials`, + ); + } + + const jobCount = [...body.matchAll(/^ runs-on:\s+/gm)].length; + const timeoutCount = [...body.matchAll(/^ timeout-minutes:\s+\d+\s*$/gm)].length; + assert.equal(timeoutCount, jobCount, `${path} must bound every job with timeout-minutes`); +} + +const ci = workflows.find(({ path }) => path.endsWith('/ci.yml'))?.body ?? ''; +assert.match( + ci, + /^permissions:\n contents:\s+read$/m, + 'CI must use explicit read-only permissions', +); +for (const command of [ + 'npm run check:docs', + 'npm run check:links', + 'npm run check:links:external', +]) { + assert.ok(ci.includes(`run: ${command}`), `CI must run ${command}`); +} + +const proof = workflows.find(({ path }) => path.endsWith('/prove-mcp-published.yml'))?.body ?? ''; +for (const input of ['expected_version', 'expected_integrity', 'expected_git_head']) { + assert.match( + proof, + new RegExp(`^ ${input}:$`, 'm'), + `published proof must require ${input}`, + ); +} + +const release = workflows.find(({ path }) => path.endsWith('/release.yml'))?.body ?? ''; +for (const input of ['expected_version', 'expected_commit', 'confirm_publish']) { + assert.match(release, new RegExp(`^ ${input}:$`, 'm'), `release must require ${input}`); +} +const authorizeJobStart = release.indexOf(' authorize-release:'); +const buildJobStart = release.indexOf(' build-release-artifacts:'); +const publishJobStart = release.indexOf(' publish-npm:'); +const proofJobStart = release.indexOf(' prove-published-npm:'); +const registryJobStart = release.indexOf(' publish-mcp-registry:'); +const githubReleaseJobStart = release.indexOf(' github-release:'); +assert.notEqual(authorizeJobStart, -1, 'release must define the authorization job'); +assert.notEqual(buildJobStart, -1, 'release must define the unprivileged build job'); +assert.notEqual(publishJobStart, -1, 'release must define the npm publish job'); +assert.notEqual(proofJobStart, -1, 'release must define the unprivileged npm proof job'); +assert.notEqual(registryJobStart, -1, 'release must define the MCP Registry publish job'); +assert.notEqual(githubReleaseJobStart, -1, 'release must define the GitHub release job'); +assert.ok( + authorizeJobStart < buildJobStart && + buildJobStart < publishJobStart && + publishJobStart < proofJobStart && + proofJobStart < registryJobStart && + registryJobStart < githubReleaseJobStart, + 'release jobs must keep authorization, build, publish, proof, registry, and release separated', +); +const authorizeJob = release.slice(authorizeJobStart, buildJobStart); +const buildJob = release.slice(buildJobStart, publishJobStart); +const publishJob = release.slice(publishJobStart, proofJobStart); +const proofJob = release.slice(proofJobStart, registryJobStart); +const registryJob = release.slice(registryJobStart, githubReleaseJobStart); +assert.match( + authorizeJob, + /^ permissions:\n contents:\s+read$/m, + 'release inputs must be authorized in an unprivileged read-only job', +); +assert.doesNotMatch( + authorizeJob, + /^ (contents|id-token|packages|pages):\s+write$/m, + 'release authorization must not receive write permissions', +); +assert.match( + authorizeJob, + /gh api "repos\/\$GITHUB_REPOSITORY\/git\/ref\/heads\/main"/, + 'release authorization must resolve the current main ref through GitHub', +); +assert.doesNotMatch( + authorizeJob, + /test "\$REMOTE_MAIN" = "\$EXPECTED_COMMIT"/, + 'authorization must not block an exact partial-release recovery after main advances', +); +assert.doesNotMatch( + authorizeJob, + /test "\$GITHUB_SHA_AT_DISPATCH" = "\$EXPECTED_COMMIT"/, + 'authorization must allow a historical reviewed main commit only for downstream exact-artifact recovery', +); +assert.equal( + [...authorizeJob.matchAll(/repos\/\$GITHUB_REPOSITORY\/compare\//g)].length, + 2, + 'authorization must prove both expected-to-dispatch and dispatch-to-current-main ancestry', +); +assert.match( + authorizeJob, + /compare\/\$EXPECTED_COMMIT\.\.\.\$GITHUB_SHA_AT_DISPATCH[\s\S]*test "\$EXPECTED_MERGE_BASE" = "\$EXPECTED_COMMIT"/, + 'the reviewed release commit must be an ancestor of main at dispatch', +); +assert.match( + authorizeJob, + /compare\/\$GITHUB_SHA_AT_DISPATCH\.\.\.\$REMOTE_MAIN[\s\S]*test "\$DISPATCH_MERGE_BASE" = "\$GITHUB_SHA_AT_DISPATCH"/, + 'main at dispatch must still be an ancestor of current main', +); +assert.match( + authorizeJob, + /release-manifest\.json\?ref=\$EXPECTED_COMMIT/, + 'authorization must hash the release manifest from the exact reviewed commit', +); +assert.match( + authorizeJob, + /manifest-sha512=/, + 'authorization must export the reviewed release manifest SHA-512', +); +assert.match( + buildJob, + /^ needs:\s+authorize-release$/m, + 'the unprivileged build must depend on exact release authorization', +); +assert.match( + buildJob, + /^ permissions:\n contents:\s+read$/m, + 'the build job must use explicit contents-read-only permissions', +); +assert.doesNotMatch(buildJob, /^ id-token:\s+write$/m, 'the build job must not mint OIDC'); +assert.match( + buildJob, + /uses:\s+actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02/, + 'the build must transfer prepacked artifacts with pinned upload-artifact v4.6.2', +); +assert.match( + buildJob, + /node scripts\/pack-release-artifacts\.mjs/, + 'the unprivileged job must prepack all authorized npm artifacts', +); +assert.match( + buildJob, + /gitHead:\s+process\.env\.EXPECTED_COMMIT/, + 'prepacked manifests must embed the exact reviewed gitHead before leaving the unprivileged job', +); +assert.match( + publishJob, + /^ needs:\s+\[authorize-release, build-release-artifacts\]$/m, + 'the npm OIDC job must consume only authorized, prebuilt artifacts', +); +assert.match(publishJob, /^ environment:\s+npm$/m, 'the npm OIDC job must use npm environment'); +assert.match( + publishJob, + /^ permissions:\n contents:\s+read\n id-token:\s+write$/m, + 'the npm publish job must receive only contents-read and OIDC permissions', +); +assert.match( + publishJob, + /uses:\s+actions\/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093/, + 'the npm publish job must consume prepacked artifacts with pinned download-artifact v4.3.0', +); +assert.doesNotMatch( + publishJob, + /npm (?:ci|install|exec changeset|pack)|npm run (?:build|verify)/, + 'the npm OIDC job must not install, build, verify, pack, or run Changesets', +); +const privilegedCheckout = publishJob.indexOf('uses: actions/checkout@'); +const privilegedCommitCheck = publishJob.indexOf( + 'name: Bind npm publication to the reviewed checkout', +); +const privilegedSetup = publishJob.indexOf('uses: actions/setup-node@'); +const privilegedDownload = publishJob.indexOf('uses: actions/download-artifact@'); +assert.ok( + privilegedCheckout < privilegedCommitCheck && + privilegedCommitCheck < privilegedSetup && + privilegedSetup < privilegedDownload, + 'the npm OIDC job must checkout exact code, bind HEAD, then download artifacts', +); +assert.match( + publishJob.slice(privilegedCheckout, privilegedCommitCheck), + /ref:\s+\$\{\{ needs\.authorize-release\.outputs\.reviewed-commit \}\}[\s\S]*fetch-depth:\s+1[\s\S]*persist-credentials:\s+false/, + 'the npm OIDC checkout must be shallow, exact, and must not persist GitHub credentials', +); +assert.match( + publishJob.slice(privilegedCommitCheck, privilegedSetup), + /test "\$\(git rev-parse HEAD\)" = "\$EXPECTED_COMMIT"/, + 'the npm OIDC job must immediately bind checkout HEAD to the reviewed commit', +); +const privilegedArtifactValidation = publishJob.indexOf( + 'assert.deepEqual(actualFiles, expectedFiles', +); +const privilegedPreflightCounter = publishJob.indexOf('existing_exact_count=0'); +const privilegedPreflightLoop = publishJob.indexOf( + "while IFS=$'\\t' read -r package_name", + privilegedPreflightCounter, +); +const privilegedConditionalMainGate = publishJob.indexOf( + 'if [[ "$existing_exact_count" -eq 0 ]]; then', +); +const privilegedMainCheck = publishJob.indexOf( + 'REMOTE_MAIN="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main"', +); +const privilegedPublishLoop = publishJob.indexOf( + "while IFS=$'\\t' read -r package_name", + privilegedPreflightLoop + 1, +); +assert.ok( + privilegedArtifactValidation >= 0 && + privilegedArtifactValidation < privilegedPreflightCounter && + privilegedPreflightCounter < privilegedPreflightLoop && + privilegedPreflightLoop < privilegedConditionalMainGate && + privilegedConditionalMainGate < privilegedMainCheck && + privilegedMainCheck < privilegedPublishLoop, + 'all five versions must be preflighted before a conditional main gate and the first irreversible npm publish', +); +assert.match( + publishJob.slice(privilegedPreflightLoop, privilegedConditionalMainGate), + /test "\$live_integrity" = "\$integrity"[\s\S]*E404/, + 'preflight must reject every integrity mismatch and distinguish only an absent version', +); +assert.match( + publishJob.slice(privilegedConditionalMainGate, privilegedPublishLoop), + /if \[\[ "\$existing_exact_count" -eq 0 \]\]; then[\s\S]*test "\$REMOTE_MAIN" = "\$EXPECTED_COMMIT"[\s\S]*fi/, + 'current main must gate only a brand-new release with no exact artifact already published', +); +assert.doesNotMatch( + publishJob.slice(privilegedPreflightCounter, privilegedPublishLoop), + /npm publish/, + 'preflight and the conditional mutable-main gate must finish before any npm publication', +); +assert.match( + publishJob.slice(privilegedPublishLoop), + /preexisting_exact\["\$package_name"\][\s\S]*npm publish "\$tarball" --ignore-scripts --provenance/, + 'the publish loop must skip exact existing artifacts and publish only remaining reviewed tarballs', +); +for (const variable of [ + 'EXPECTED_COMMIT', + 'EXPECTED_RELEASE_VERSION', + 'EXPECTED_RELEASE_MANIFEST_SHA512', + 'EXPECTED_ARTIFACT_MANIFEST_SHA512', +]) { + assert.match( + publishJob, + new RegExp(`^ ${variable}:`, 'm'), + `the npm OIDC job must revalidate ${variable}`, + ); +} +assert.match( + publishJob, + /assert\.equal\(artifacts\.length, 5/, + 'the npm OIDC job must require exactly five prepacked tarballs', +); +assert.match( + publishJob, + /assert\.deepEqual\(actualFiles, expectedFiles/, + 'the npm OIDC job must reject extra and missing transferred files', +); +assert.match( + publishJob, + /createHash\(['"]sha512['"]\)/, + 'the npm OIDC job must recompute SHA-512 for transferred manifests and tarballs', +); +assert.match( + publishJob, + /assert\.equal\([\s\S]*tarballPackage\.gitHead,[\s\S]*process\.env\.EXPECTED_COMMIT/, + 'the npm OIDC job must prove each tarball manifest is bound to the reviewed gitHead', +); +assert.match( + publishJob, + /npm publish "\$tarball" --ignore-scripts --provenance/, + 'the npm OIDC job must publish each literal reviewed tarball with lifecycle scripts disabled', +); +assert.match( + publishJob, + /NPM_CONFIG_REGISTRY:\s+https:\/\/registry\.npmjs\.org\//, + 'trusted publication must use the canonical npm registry', +); +for (const config of ['NPM_CONFIG_USERCONFIG', 'NPM_CONFIG_GLOBALCONFIG']) { + assert.match( + publishJob, + new RegExp(`: > "\\$${config}"`), + `trusted publication must isolate ambient ${config}`, + ); +} +assert.doesNotMatch( + release, + /changeset publish/, + 'the release workflow must never let Changesets repack inside the OIDC job', +); +assert.equal( + [...release.matchAll(/^ environment:\s+npm$/gm)].length, + 1, + 'exactly one release job may use the npm trusted-publishing environment', +); +assert.equal( + [...release.matchAll(/^ id-token:\s+write$/gm)].length, + 2, + 'only the npm and MCP Registry publication jobs may mint OIDC tokens', +); +assert.equal( + [ + ...release.matchAll( + /uses:\s+actions\/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02/g, + ), + ].length, + 1, + 'release must upload the prepacked npm bundle exactly once', +); +assert.equal( + [ + ...release.matchAll( + /uses:\s+actions\/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093/g, + ), + ].length, + 1, + 'only the npm OIDC job may download the prepacked npm bundle', +); +assert.equal( + [...release.matchAll(/npm publish "\$tarball" --ignore-scripts --provenance/g)].length, + 1, + 'the release workflow must contain one controlled literal-tarball publish loop', +); +assert.match( + proofJob, + /^ needs:\s+\[authorize-release, build-release-artifacts, publish-npm\]$/m, + 'unprivileged live npm proof must wait for the exact publish', +); +assert.match( + proofJob, + /^ permissions:\n contents:\s+read$/m, + 'the npm proof job must use explicit contents-read-only permissions', +); +assert.doesNotMatch(proofJob, /^ id-token:\s+write$/m, 'npm proof must not mint OIDC'); +assert.match(release, /node-version:\s+24/, 'release must use Node 24'); +assert.match(release, /npm@12\.0\.1/, 'release must pin the reviewed npm CLI'); +assert.match( + release, + /package-manager-cache:\s+false/, + 'release builds must disable package-manager caching', +); +assert.match( + proofJob, + /npm run proof:mcp:published/, + 'release must prove the exact live MCP artifact', +); +assert.match(release, /gh release create/, 'release must create the reviewed GitHub release'); +assert.match(release, /gh release view/, 'GitHub release creation must be safe to rerun'); +assert.match( + release, + /--method POST \\\n\s+"repos\/\$GITHUB_REPOSITORY\/git\/refs"/, + 'release must atomically create the exact tag through the Git refs API', +); +assert.match( + release, + /\[\[ "\$tag_response" == \*"HTTP 422"\* \]\]/, + 'release reruns may tolerate only an existing-ref response from tag creation', +); +assert.equal( + [...release.matchAll(/gh api "repos\/\$GITHUB_REPOSITORY\/commits\/\$tag" --jq '\.sha'/g)].length, + 2, + 'release must peel and verify the tag commit before and after release creation', +); +assert.match(release, /--verify-tag/, 'release creation must require the precreated exact tag'); +assert.doesNotMatch( + release, + /--target|--generate-notes/, + 'release creation must not retarget a tag or append nondeterministic generated notes', +); +assert.match( + release, + /assert\.equal\(process\.env\.EXISTING_BODY, process\.env\.EXPECTED_NOTES\)/, + 'an existing release body must exactly equal the deterministic reviewed notes', +); +assert.doesNotMatch(release, /NPM_TOKEN|NODE_AUTH_TOKEN|secrets\./, 'release must not use tokens'); +for (const variable of ['EXPECTED_MCP_VERSION', 'EXPECTED_MCP_INTEGRITY', 'EXPECTED_GIT_HEAD']) { + assert.match( + proof, + new RegExp(`^ ${variable}:`, 'm'), + `published proof must pass ${variable}`, + ); +} + +const docs = workflows.find(({ path }) => path.endsWith('/docs.yml'))?.body ?? ''; +assert.doesNotMatch(docs, /^\s+tags:\s*$/m, 'docs must not deploy directly from mutable tags'); +assert.match(docs, /^\s+branches:\n\s+- main$/m, 'docs push deployments must come from main'); +const docsBuildStart = docs.indexOf(' build:'); +const docsDeployStart = docs.indexOf(' deploy:'); +assert.notEqual(docsBuildStart, -1, 'docs must define the build job'); +assert.notEqual(docsDeployStart, -1, 'docs must define the deploy job'); +const docsBuild = docs.slice(docsBuildStart, docsDeployStart); +const docsDeploy = docs.slice(docsDeployStart); +assert.match( + docsBuild, + /^ permissions:\n contents:\s+read$/m, + 'docs build must use explicit contents-read-only permissions', +); +assert.doesNotMatch( + docsBuild, + /^ (pages|id-token):\s+write$/m, + 'docs build must not receive deployment credentials', +); +assert.match(docsDeploy, /^ pages:\s+write$/m, 'only docs deploy may write Pages'); +assert.match(docsDeploy, /^ id-token:\s+write$/m, 'only docs deploy may mint the Pages token'); + +const rootPackage = JSON.parse(await readFile('package.json', 'utf8')); +const postPublishProof = await readFile('scripts/prove-published-release.mjs', 'utf8'); +const mcpRegistryProof = await readFile('scripts/prove-mcp-registry-record.mjs', 'utf8'); +const releasePacker = await readFile('scripts/pack-release-artifacts.mjs', 'utf8'); +const releaseManifest = JSON.parse(await readFile('release-manifest.json', 'utf8')); +const publishedPackages = releaseManifest.packages.filter((entry) => entry.publish); +assert.equal(releaseManifest.packages.length, 10, 'release manifest must cover all ten packages'); +assert.equal(publishedPackages.length, 5, 'release manifest must authorize exactly five publishes'); +assert.match( + releasePacker, + /['"]--ignore-scripts['"]/, + 'release prepacking must not rerun package lifecycle scripts after verified builds', +); +assert.match( + release, + /node scripts\/pack-release-artifacts\.mjs/, + 'release must prepack and hash every authorized artifact', +); +assert.match( + release, + /EXPECTED_RELEASE_INTEGRITIES/, + 'release must pass the complete reviewed integrity map to published proof', +); +assert.match( + release, + /node scripts\/prove-published-release\.mjs/, + 'release must prove every published package from a generic isolated consumer', +); +assert.match( + release, + /for attempt in \{1\.\.12\}/, + 'post-publish registry convergence must use bounded retries', +); +assert.match( + release, + /RELEASE_ARTIFACTS/, + 'GitHub release notes must receive the complete package and integrity set', +); +assert.match( + postPublishProof, + /\['audit', 'signatures'\]/, + 'post-publish proof must verify npm registry signatures for the clean exact install', +); +assert.match( + postPublishProof, + /attestations/, + 'post-publish proof must validate provenance attestations', +); +assert.match( + release, + /releases\/download\/v1\.7\.9\/mcp-publisher_linux_amd64\.tar\.gz/, + 'MCP publication must pin the reviewed mcp-publisher v1.7.9 asset', +); +assert.match( + release, + /ab128162b0616090b47cf245afe0a23f3ef08936fdce19074f5ba0a4469281ac/, + 'MCP publication must verify the reviewed publisher asset SHA-256', +); +assert.doesNotMatch( + release, + /releases\/latest|curl[^\n]*\|/, + 'MCP publication must not execute a curl pipe or resolve a mutable latest asset', +); +assert.match(release, /mcp-publisher login github-oidc/, 'MCP publication must use GitHub OIDC'); +assert.match( + release, + /mcp-publisher publish/, + 'release must publish server.json to the MCP Registry', +); +assert.match( + release, + /node scripts\/prove-mcp-registry-record\.mjs/, + 'release must prove the exact live official MCP Registry record', +); +assert.match( + mcpRegistryProof, + /registry\.modelcontextprotocol\.io\/v0\.1\/servers/, + 'MCP registry proof must use the official frozen v0.1 API', +); +assert.match( + registryJob, + /^ needs:\s+\[authorize-release, prove-published-npm\]$/m, + 'MCP Registry publication must wait for authorization and complete npm proof', +); +assert.match( + registryJob, + /^ permissions:\n contents:\s+read\n id-token:\s+write$/m, + 'MCP Registry publication must use only contents-read and OIDC permissions', +); +const registryMainCheck = registryJob.indexOf('name: Revalidate current main before checkout'); +const registryCheckout = registryJob.indexOf('uses: actions/checkout@'); +assert.equal( + registryMainCheck, + -1, + 'MCP publication must not recheck mutable main after npm publication', +); +assert.ok(registryCheckout >= 0, 'MCP publication must checkout the reviewed commit'); +assert.doesNotMatch( + proofJob, + /git\/ref\/heads\/main|REMOTE_MAIN/, + 'live npm proof must continue against the authorized commit after npm publication', +); +assert.doesNotMatch( + registryJob, + /git\/ref\/heads\/main|REMOTE_MAIN/, + 'MCP publication must continue against the authorized commit after npm publication', +); +assert.equal( + [...release.matchAll(/git\/ref\/heads\/main/g)].length, + 2, + 'mutable main may be checked only during authorization and immediately before npm publication', +); +const publisherDownload = registryJob.indexOf('curl --proto'); +const publisherChecksum = registryJob.indexOf('sha256sum --check --strict'); +const publisherExtract = registryJob.indexOf('tar --extract'); +assert.ok( + publisherDownload >= 0 && + publisherDownload < publisherChecksum && + publisherChecksum < publisherExtract, + 'MCP publisher must be downloaded, checksum-verified, then extracted in that order', +); +assert.match( + registryJob, + /MCP_REGISTRY_ALLOW_MISSING=1/, + 'reruns must distinguish an exact existing MCP record from a missing record', +); +assert.match( + release, + /^ github-release:\n needs:\s+\[authorize-release, build-release-artifacts, prove-published-npm, publish-mcp-registry\]$/m, + 'GitHub release creation must wait for exact artifacts and both live registry proofs', +); +assert.match(rootPackage.scripts.verify, /npm run check:workflows/, 'verify must check workflows'); +assert.match( + rootPackage.scripts.verify, + /npm run test:release-supply-chain/, + 'verify must execute release supply-chain regression tests', +); +assert.match( + rootPackage.scripts.verify, + /npm run audit:high/, + 'verify must reject high-risk advisories', +); +assert.equal( + rootPackage.scripts['check:links'], + 'node scripts/check-doc-links.mjs', + 'package scripts must expose the complete local documentation link check', +); +assert.equal( + rootPackage.scripts['check:links:external'], + 'node scripts/check-doc-links.mjs --external', + 'package scripts must expose the external documentation link check', +); +assert.match( + rootPackage.scripts.verify, + /npm run check:links/, + 'verify must check local doc links', +); + +console.log(`Verified ${workflowPaths.length} release workflows use bounded, immutable controls.`); diff --git a/scripts/compute-npm-integrity.mjs b/scripts/compute-npm-integrity.mjs new file mode 100644 index 0000000..e6b7d25 --- /dev/null +++ b/scripts/compute-npm-integrity.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +const tarballPath = process.argv[2]; +assert(tarballPath, 'Usage: node scripts/compute-npm-integrity.mjs '); + +const digest = createHash('sha512') + .update(await readFile(tarballPath)) + .digest('base64'); +process.stdout.write(`sha512-${digest}\n`); diff --git a/scripts/pack-release-artifacts.mjs b/scripts/pack-release-artifacts.mjs new file mode 100644 index 0000000..2c27994 --- /dev/null +++ b/scripts/pack-release-artifacts.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const argumentsByName = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + const name = process.argv[index]; + const value = process.argv[index + 1]; + assert(name?.startsWith('--') && value, 'Arguments must be --name value pairs'); + argumentsByName.set(name, value); +} + +const destination = argumentsByName.get('--destination'); +const output = argumentsByName.get('--output'); +assert(destination, '--destination is required'); +assert(output, '--output is required'); + +const release = JSON.parse(await readFile('release-manifest.json', 'utf8')); +const packages = release.packages.filter((entry) => entry.publish); +assert.equal(packages.length, 5, 'exactly five release artifacts must be packed'); +await Promise.all([ + mkdir(resolve(destination), { recursive: true }), + mkdir(dirname(resolve(output)), { recursive: true }), +]); + +function npmCommand(args) { + if (process.env.npm_execpath) { + return { command: process.execPath, args: [process.env.npm_execpath, ...args] }; + } + return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args }; +} + +async function pack(entry) { + const command = npmCommand([ + 'pack', + '--workspace', + entry.name, + '--ignore-scripts', + '--json', + '--pack-destination', + resolve(destination), + ]); + const { stdout } = await execFileAsync(command.command, command.args, { + maxBuffer: 20 * 1_024 * 1_024, + }); + const jsonStart = stdout.lastIndexOf('\n['); + const packed = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); + assert.equal(packed.length, 1, `expected one artifact for ${entry.name}`); + const artifact = packed[0]; + assert.equal(artifact.name, entry.name, `packed the wrong workspace for ${entry.name}`); + assert.equal(artifact.version, entry.version, `${entry.name} packed at the wrong version`); + assert( + artifact.files.some((file) => file.path === 'package.json'), + `${entry.name} artifact is missing package.json`, + ); + assert( + artifact.files.some((file) => file.path === 'dist/index.js'), + `${entry.name} artifact is missing dist/index.js`, + ); + const bytes = await readFile(resolve(destination, artifact.filename)); + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + return { + name: entry.name, + version: entry.version, + filename: artifact.filename, + integrity, + size: bytes.byteLength, + }; +} + +const artifacts = []; +for (const entry of packages) { + artifacts.push(await pack(entry)); +} + +await writeFile(resolve(output), `${JSON.stringify(artifacts, null, 2)}\n`); +process.stdout.write(`${JSON.stringify(artifacts)}\n`); diff --git a/scripts/prove-mcp-published.mjs b/scripts/prove-mcp-published.mjs new file mode 100644 index 0000000..2d53ccf --- /dev/null +++ b/scripts/prove-mcp-published.mjs @@ -0,0 +1,390 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const execFileAsync = promisify(execFile); +const expectedVersion = process.env.EXPECTED_MCP_VERSION?.trim(); +const expectedIntegrity = process.env.EXPECTED_MCP_INTEGRITY?.trim(); +const expectedGitHead = process.env.EXPECTED_GIT_HEAD?.trim(); +assert(expectedVersion, 'EXPECTED_MCP_VERSION is required'); +assert.match( + expectedVersion, + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, + 'EXPECTED_MCP_VERSION must be an exact semantic version', +); +assert(expectedIntegrity, 'EXPECTED_MCP_INTEGRITY is required'); +assert.match( + expectedIntegrity, + /^sha512-[A-Za-z0-9+/=]+$/, + 'EXPECTED_MCP_INTEGRITY must be an exact sha512 npm integrity', +); +assert(expectedGitHead, 'EXPECTED_GIT_HEAD is required'); +assert.match(expectedGitHead, /^[0-9a-f]{40}$/i, 'EXPECTED_GIT_HEAD must be a 40-character SHA'); +const packageSpec = `@buzzr/mcp@${expectedVersion}`; +const npmExecPath = process.env.npm_execpath; +assert(npmExecPath, 'Run the published proof through npm so npm_execpath is available'); +const npxCliPath = resolve(dirname(npmExecPath), 'npx-cli.js'); + +const coreToolNames = [ + 'grade_dfs_entry', + 'grade_dfs_entries', + 'validate_dfs_entry', + 'list_book_policies', + 'fair_line', + 'closing_line_value', + 'parlay_value', + 'kelly_stake', + 'summarize_bet_history', + 'predict_game_buzz', + 'rank_games', +]; + +function execNpm(args, options) { + return execFileAsync(process.execPath, [npmExecPath, ...args], options); +} + +function parseToolResult(result) { + assert.equal(result.content.length, 1); + assert.equal(result.content[0].type, 'text'); + return JSON.parse(result.content[0].text); +} + +async function withDeadline(promise, label, timeoutMs = 30_000) { + let timeout; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), 'buzzr-mcp-published-')); +const cache = join(temporaryRoot, 'npm-cache'); +const isolatedHome = join(temporaryRoot, 'home'); +const isolatedAppData = join(temporaryRoot, 'appdata'); +const isolatedLocalAppData = join(temporaryRoot, 'local-appdata'); +const isolatedTemp = join(temporaryRoot, 'tmp'); +const emptyUserConfig = join(temporaryRoot, 'empty-user.npmrc'); +const emptyGlobalConfig = join(temporaryRoot, 'empty-global.npmrc'); +const repositoryBin = resolve('node_modules', '.bin'); +const path = (process.env.PATH ?? '') + .split(delimiter) + .filter((entry) => resolve(entry) !== repositoryBin) + .join(delimiter); +const inheritedEnvironmentKeys = ['COMSPEC', 'LANG', 'LC_ALL', 'PATHEXT', 'SHELL', 'SYSTEMROOT']; +const environment = Object.fromEntries( + inheritedEnvironmentKeys.flatMap((key) => + process.env[key] === undefined ? [] : [[key, process.env[key]]], + ), +); +Object.assign(environment, { + APPDATA: isolatedAppData, + HOME: isolatedHome, + LOCALAPPDATA: isolatedLocalAppData, + NO_COLOR: '1', + PATH: path, + TEMP: isolatedTemp, + TMP: isolatedTemp, + TMPDIR: isolatedTemp, + USERPROFILE: isolatedHome, + npm_config_cache: cache, + npm_config_userconfig: emptyUserConfig, + npm_config_globalconfig: emptyGlobalConfig, + npm_config_registry: 'https://registry.npmjs.org/', + npm_config_ignore_scripts: 'true', + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_update_notifier: 'false', +}); + +try { + await Promise.all( + [cache, isolatedHome, isolatedAppData, isolatedLocalAppData, isolatedTemp].map((directory) => + mkdir(directory, { recursive: true }), + ), + ); + await Promise.all([writeFile(emptyUserConfig, ''), writeFile(emptyGlobalConfig, '')]); + const { stdout: latestOutput } = await execNpm( + ['view', '@buzzr/mcp', 'dist-tags.latest', '--json'], + { cwd: temporaryRoot, env: environment }, + ); + const latest = JSON.parse(latestOutput); + assert.equal( + latest, + expectedVersion, + `npm latest is ${latest}; expected ${expectedVersion}. Refusing to prove the wrong release.`, + ); + + const viewField = async (field) => { + const { stdout } = await execNpm(['view', packageSpec, field, '--json'], { + cwd: temporaryRoot, + env: environment, + }); + assert(stdout.trim(), `Published package has no ${field} metadata`); + return JSON.parse(stdout); + }; + const [integrity, gitHead, tarball, provenance] = await Promise.all([ + viewField('dist.integrity'), + viewField('gitHead'), + viewField('dist.tarball'), + viewField('dist.attestations.provenance'), + ]); + assert.equal(integrity, expectedIntegrity, 'Published integrity does not match reviewed release'); + assert.equal(gitHead, expectedGitHead, 'Published gitHead does not match reviewed release'); + assert.equal( + new URL(tarball).origin, + 'https://registry.npmjs.org', + 'Published tarball is not hosted by the npm registry', + ); + assert(provenance, 'Published package has no npm provenance attestation'); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [npxCliPath, '-y', packageSpec], + cwd: temporaryRoot, + env: environment, + stderr: 'pipe', + }); + let stderr = ''; + let stderrOverflow = false; + transport.stderr?.on('data', (chunk) => { + if (stderrOverflow) { + return; + } + const next = stderr + chunk.toString(); + if (Buffer.byteLength(next) > 256 * 1024) { + stderrOverflow = true; + return; + } + stderr = next; + }); + + const client = new Client({ name: 'buzzr-published-proof', version: '1.0.0' }); + try { + await withDeadline(client.connect(transport), 'Published MCP initialization'); + assert.deepEqual(client.getServerVersion(), { name: 'buzzr', version: expectedVersion }); + + const listed = await withDeadline(client.listTools(), 'Published MCP tools/list'); + const listedNames = new Set(listed.tools.map((tool) => tool.name)); + coreToolNames.forEach((toolName) => { + assert(listedNames.has(toolName), `Published MCP is missing ${toolName}`); + }); + + const dfs = parseToolResult( + await withDeadline( + client.callTool({ + name: 'grade_dfs_entry', + arguments: { + entryId: 'published-proof', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Player One', + league: 'NBA', + propType: 'points', + line: 25.5, + direction: 'over', + actual: 31, + }, + { + legId: 'leg-2', + playerName: 'Player Two', + league: 'NBA', + propType: 'points', + line: 27.5, + direction: 'over', + actual: 33, + }, + ], + }, + }), + 'Published MCP DFS call', + ), + ); + assert.equal(dfs.status, 'won'); + assert.equal(dfs.payout.total, 30); + + const dfsBatch = parseToolResult( + await withDeadline( + client.callTool({ + name: 'grade_dfs_entries', + arguments: { + entries: [ + { + entryId: 'published-batch-proof', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Player One', + league: 'NBA', + propType: 'points', + line: 25.5, + direction: 'over', + actual: 31, + }, + { + legId: 'leg-2', + playerName: 'Player Two', + league: 'NBA', + propType: 'points', + line: 27.5, + direction: 'over', + actual: 33, + }, + ], + }, + ], + concurrency: 1, + }, + }), + 'Published MCP DFS batch call', + ), + ); + assert.equal(dfsBatch.contractVersion, '1'); + assert.equal(dfsBatch.summary.settled, 1); + + const odds = parseToolResult( + await withDeadline( + client.callTool({ + name: 'fair_line', + arguments: { selected: -110, opposite: -110 }, + }), + 'Published MCP odds call', + ), + ); + assert.equal(odds.fairProbability, 0.5); + + const closingLine = parseToolResult( + await withDeadline( + client.callTool({ + name: 'closing_line_value', + arguments: { placedAmericanOdds: 110, closingAmericanOdds: -105 }, + }), + 'Published MCP closing line call', + ), + ); + assert.deepEqual(closingLine, { + contractVersion: '1', + clvPercent: 3.6, + beatClosingLine: true, + }); + + const betHistory = parseToolResult( + await withDeadline( + client.callTool({ + name: 'summarize_bet_history', + arguments: { + bets: [ + { + id: 'published-bet', + userId: 'published-user', + sportsbookSlug: 'draftkings', + kind: 'straight', + status: 'won', + stake: 10, + payout: 25, + placedAt: '2026-07-15T17:00:00.000Z', + settledAt: '2026-07-15T18:00:00.000Z', + }, + ], + period: 'day', + }, + }), + 'Published MCP bet history call', + ), + ); + assert.equal(betHistory.contractVersion, '1'); + assert.equal(betHistory.period, 'day'); + assert.equal(betHistory.overall.totalBets, 1); + + const entertainment = parseToolResult( + await withDeadline( + client.callTool({ + name: 'predict_game_buzz', + arguments: { + league: 'NBA', + homeTeam: 'Lakers', + awayTeam: 'Celtics', + startsAt: '2030-07-17T19:30:00-04:00', + }, + }), + 'Published MCP entertainment call', + ), + ); + assert.equal(typeof entertainment.score, 'number'); + + const invalid = await withDeadline( + client.callTool({ + name: 'fair_line', + arguments: { selected: 0, opposite: -110 }, + }), + 'Published MCP invalid call', + ); + assert.equal(invalid.isError, true); + assert.equal(invalid.content[0].type, 'text'); + assert.equal(parseToolResult(invalid).error.code, 'invalid_input'); + + const adversarialValidation = await withDeadline( + client.callTool({ + name: 'summarize_bet_history', + arguments: { bets: Array.from({ length: 5_000 }, () => ({})) }, + }), + 'Published MCP adversarial validation call', + ); + assert.equal(adversarialValidation.isError, true); + assert.equal(parseToolResult(adversarialValidation).error.code, 'invalid_input'); + assert.ok( + Buffer.byteLength(JSON.stringify(adversarialValidation), 'utf8') < 65_536, + 'Published MCP validation error must stay below 64 KiB', + ); + + const concurrent = await withDeadline( + Promise.all( + Array.from({ length: 12 }, (_, index) => + client.callTool({ + name: 'fair_line', + arguments: { + selected: -110 - index, + opposite: -110, + selectedSide: `published-${index}`, + }, + }), + ), + ), + 'Published MCP concurrent calls', + ); + concurrent.forEach((result, index) => { + assert.equal(parseToolResult(result).selectedSide, `published-${index}`); + }); + + assert.equal(stderrOverflow, false, 'Published MCP stderr exceeded limit'); + assert.match(stderr, new RegExp(`buzzr MCP server v${expectedVersion} listening on stdio`)); + process.stdout.write( + `Published @buzzr/mcp@${expectedVersion} passed exact clean-cache npx proof with ${listed.tools.length} tools\n`, + ); + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + } +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/prove-mcp-registry-record.mjs b/scripts/prove-mcp-registry-record.mjs new file mode 100644 index 0000000..826e4c2 --- /dev/null +++ b/scripts/prove-mcp-registry-record.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const officialServersEndpoint = 'https://registry.modelcontextprotocol.io/v0.1/servers'; +const [release, expectedServer] = await Promise.all([ + readFile('release-manifest.json', 'utf8').then(JSON.parse), + readFile('server.json', 'utf8').then(JSON.parse), +]); +assert.equal(`${release.registry.mcpApi}/servers`, officialServersEndpoint); +assert.equal(expectedServer.name, release.registry.mcpServerName); +assert.equal(expectedServer.version, release.releaseVersion); + +const recordUrl = `${officialServersEndpoint}/${encodeURIComponent(expectedServer.name)}/versions/${encodeURIComponent(expectedServer.version)}`; +const response = await fetch(recordUrl, { + headers: { accept: 'application/json', 'user-agent': 'buzzr-mcp-registry-proof/5.1' }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), +}); + +if (response.status === 404 && process.env.MCP_REGISTRY_ALLOW_MISSING === '1') { + process.stderr.write(`${expectedServer.name}@${expectedServer.version} is not published yet.\n`); + process.exitCode = 3; +} else { + assert.equal(response.ok, true, `${recordUrl} returned HTTP ${response.status}`); + const record = await response.json(); + assert.equal(record.server?.name, expectedServer.name); + assert.equal(record.server?.version, expectedServer.version); + assert.equal(record.server?.title, expectedServer.title); + assert.equal(record.server?.description, expectedServer.description); + assert.equal(record.server?.websiteUrl, expectedServer.websiteUrl); + assert.equal(record.server?.$schema, expectedServer.$schema); + assert.deepEqual(record.server?.repository, expectedServer.repository); + assert.equal(record.server?.packages?.length, 1); + const publishedPackage = record.server.packages[0]; + const expectedPackage = expectedServer.packages[0]; + assert.equal(publishedPackage.registryType, expectedPackage.registryType); + assert.equal(publishedPackage.identifier, expectedPackage.identifier); + assert.equal(publishedPackage.version, expectedPackage.version); + assert.deepEqual(publishedPackage.transport, expectedPackage.transport); + const official = record._meta?.['io.modelcontextprotocol.registry/official']; + assert.equal(official?.status, 'active'); + assert.equal(typeof official?.isLatest, 'boolean'); + assert.equal(Number.isNaN(Date.parse(official?.publishedAt)), false); + + process.stdout.write( + `Proved exact official MCP Registry record ${expectedServer.name}@${expectedServer.version}.\n`, + ); +} diff --git a/scripts/prove-published-release.mjs b/scripts/prove-published-release.mjs new file mode 100644 index 0000000..72c0b73 --- /dev/null +++ b/scripts/prove-published-release.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const expectedCommit = process.env.EXPECTED_RELEASE_COMMIT?.trim(); +const expectedIntegritiesJson = process.env.EXPECTED_RELEASE_INTEGRITIES?.trim(); +assert.match(expectedCommit ?? '', /^[0-9a-f]{40}$/, 'EXPECTED_RELEASE_COMMIT is required'); +assert(expectedIntegritiesJson, 'EXPECTED_RELEASE_INTEGRITIES is required'); + +const release = JSON.parse(await readFile('release-manifest.json', 'utf8')); +const packages = release.packages.filter((entry) => entry.publish); +const expectedIntegrities = JSON.parse(expectedIntegritiesJson); +assert.deepEqual( + Object.keys(expectedIntegrities).sort(), + packages.map((entry) => entry.name).sort(), + 'integrity map must cover exactly the five published packages', +); +for (const integrity of Object.values(expectedIntegrities)) { + assert.match(integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); +} + +const registryUrl = new URL(release.registry.npm); +const registry = `${registryUrl.origin}/`; +assert.equal(registry, release.registry.npm, 'npm registry must be a canonical HTTPS origin'); + +function npmCommand(args) { + if (process.env.npm_execpath) { + return { command: process.execPath, args: [process.env.npm_execpath, ...args] }; + } + return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args }; +} + +async function execNpm(args, { cwd, env }) { + const command = npmCommand(args); + return execFileAsync(command.command, command.args, { + cwd, + env, + maxBuffer: 20 * 1_024 * 1_024, + }); +} + +async function fetchJson(url) { + const response = await fetch(url, { + headers: { accept: 'application/json', 'user-agent': 'buzzr-release-proof/5.1' }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }); + assert.equal(response.ok, true, `${url} returned HTTP ${response.status}`); + return response.json(); +} + +function decodeStatement(attestation) { + const payload = attestation?.bundle?.dsseEnvelope?.payload; + assert.equal(typeof payload, 'string', 'attestation is missing its signed DSSE payload'); + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); +} + +function packagePurl(name, version) { + const path = name.startsWith('@') ? `%40${name.slice(1)}` : name; + return `pkg:npm/${path}@${version}`; +} + +function integrityHex(integrity) { + return Buffer.from(integrity.slice('sha512-'.length), 'base64').toString('hex'); +} + +function assertSubject(statement, entry, integrity) { + const subject = statement.subject?.find( + (candidate) => candidate.name === packagePurl(entry.name, entry.version), + ); + assert(subject, `${entry.name} attestation subject is missing`); + assert.equal(subject.digest?.sha512, integrityHex(integrity), `${entry.name} digest drifted`); +} + +async function provePackage(entry, environment, cwd) { + const spec = `${entry.name}@${entry.version}`; + const { stdout } = await execNpm(['view', spec, '--json', '--registry', registry], { + cwd, + env: environment, + }); + const metadata = JSON.parse(stdout); + const expectedIntegrity = expectedIntegrities[entry.name]; + assert.equal(metadata.name, entry.name); + assert.equal(metadata.version, entry.version); + assert.equal(metadata.gitHead, expectedCommit, `${spec} gitHead drifted`); + assert.equal(metadata.dist?.integrity, expectedIntegrity, `${spec} integrity drifted`); + assert(Array.isArray(metadata.dist?.signatures) && metadata.dist.signatures.length > 0); + + const tarball = new URL(metadata.dist.tarball); + assert.equal( + tarball.origin, + registryUrl.origin, + `${spec} tarball came from an unexpected registry`, + ); + const attestationUrl = new URL(metadata.dist.attestations?.url); + assert.equal( + attestationUrl.origin, + registryUrl.origin, + `${spec} attestations came from an unexpected registry`, + ); + assert.equal( + metadata.dist.attestations?.provenance?.predicateType, + 'https://slsa.dev/provenance/v1', + `${spec} is missing SLSA provenance`, + ); + + const response = await fetchJson(attestationUrl); + assert(Array.isArray(response.attestations), `${spec} attestation response is malformed`); + const publishAttestation = response.attestations.find( + (candidate) => + candidate.predicateType === 'https://github.com/npm/attestation/tree/main/specs/publish/v0.1', + ); + const provenanceAttestation = response.attestations.find( + (candidate) => candidate.predicateType === 'https://slsa.dev/provenance/v1', + ); + assert(publishAttestation, `${spec} is missing npm publish provenance`); + assert(provenanceAttestation, `${spec} is missing GitHub Actions provenance`); + + const publishStatement = decodeStatement(publishAttestation); + assertSubject(publishStatement, entry, expectedIntegrity); + assert.equal(publishStatement.predicate?.name, entry.name); + assert.equal(publishStatement.predicate?.version, entry.version); + assert.equal(publishStatement.predicate?.registry, registryUrl.origin); + + const provenanceStatement = decodeStatement(provenanceAttestation); + assertSubject(provenanceStatement, entry, expectedIntegrity); + const workflow = provenanceStatement.predicate?.buildDefinition?.externalParameters?.workflow; + assert.equal(workflow?.repository, release.registry.repository); + assert.equal(workflow?.path, release.registry.workflowPath); + assert.equal(workflow?.ref, release.registry.ref); + const dependencies = provenanceStatement.predicate?.buildDefinition?.resolvedDependencies ?? []; + assert( + dependencies.some((dependency) => dependency.digest?.gitCommit === expectedCommit), + `${spec} provenance is not bound to the reviewed commit`, + ); +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), 'buzzr-published-release-')); +const paths = { + home: join(temporaryRoot, 'home'), + appData: join(temporaryRoot, 'app-data'), + localAppData: join(temporaryRoot, 'local-app-data'), + temp: join(temporaryRoot, 'temp'), + cache: join(temporaryRoot, 'npm-cache'), + userConfig: join(temporaryRoot, 'home', '.npmrc'), + globalConfig: join(temporaryRoot, 'home', 'global.npmrc'), + consumer: join(temporaryRoot, 'consumer'), +}; + +const inheritedEnvironment = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !/^(?:NPM_TOKEN|NODE_AUTH_TOKEN|NPM_CONFIG_.*AUTH.*)$/i.test(name), + ), +); +const environment = { + ...inheritedEnvironment, + HOME: paths.home, + USERPROFILE: paths.home, + APPDATA: paths.appData, + LOCALAPPDATA: paths.localAppData, + TEMP: paths.temp, + TMP: paths.temp, + TMPDIR: paths.temp, + NO_COLOR: '1', + npm_config_cache: paths.cache, + npm_config_userconfig: paths.userConfig, + npm_config_globalconfig: paths.globalConfig, + npm_config_registry: registry, + npm_config_fund: 'false', + npm_config_audit: 'false', + npm_config_update_notifier: 'false', +}; + +try { + await Promise.all( + [paths.home, paths.appData, paths.localAppData, paths.temp, paths.cache, paths.consumer].map( + (path) => mkdir(path, { recursive: true }), + ), + ); + await Promise.all([ + writeFile(paths.userConfig, `registry=${registry}\n`), + writeFile(paths.globalConfig, `registry=${registry}\n`), + writeFile( + join(paths.consumer, 'package.json'), + `${JSON.stringify({ name: 'buzzr-live-release-proof', private: true }, null, 2)}\n`, + ), + ]); + + const configuredRegistry = await execNpm(['config', 'get', 'registry'], { + cwd: paths.consumer, + env: environment, + }); + assert.equal(configuredRegistry.stdout.trim(), registry); + + for (const entry of packages) { + await provePackage(entry, environment, paths.consumer); + } + + const exactSpecs = packages.map((entry) => `${entry.name}@${entry.version}`); + await execNpm( + [ + 'install', + '--ignore-scripts', + '--package-lock=true', + '--save-exact', + '--no-audit', + '--no-fund', + ...exactSpecs, + ], + { cwd: paths.consumer, env: environment }, + ); + await execNpm(['ls', '--all'], { cwd: paths.consumer, env: environment }); + for (const entry of packages) { + const installedDirectory = join(paths.consumer, 'node_modules', ...entry.name.split('/')); + assert.equal((await lstat(installedDirectory)).isSymbolicLink(), false); + const installed = JSON.parse(await readFile(join(installedDirectory, 'package.json'), 'utf8')); + assert.equal(installed.name, entry.name); + assert.equal(installed.version, entry.version); + } + await execNpm(['audit', 'signatures'], { cwd: paths.consumer, env: environment }); + + process.stdout.write( + `Proved ${packages.length} exact npm versions, integrities, gitHeads, registry origins, signed provenance statements, and clean-install signatures from ${expectedCommit}.\n`, + ); +} finally { + if (process.env.KEEP_BUZZR_PUBLISHED_PROOF === '1') { + process.stderr.write(`Kept published release proof at ${temporaryRoot}\n`); + } else { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/test-mcp-packed.mjs b/scripts/test-mcp-packed.mjs new file mode 100644 index 0000000..ed5e5a5 --- /dev/null +++ b/scripts/test-mcp-packed.mjs @@ -0,0 +1,530 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { JSONRPCMessageSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'; + +const execFileAsync = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const workspaces = [ + '@buzzr/bets-core', + '@buzzr/dfs-engine', + '@buzzr/entertainment-engine', + '@buzzr/mcp', +]; +const coreToolNames = [ + 'grade_dfs_entry', + 'grade_dfs_entries', + 'validate_dfs_entry', + 'list_book_policies', + 'fair_line', + 'closing_line_value', + 'parlay_value', + 'kelly_stake', + 'summarize_bet_history', + 'predict_game_buzz', + 'rank_games', +]; +const maxCapturedBytes = 256 * 1024; + +function execNpm(args, options) { + if (process.env.npm_execpath) { + return execFileAsync(process.execPath, [process.env.npm_execpath, ...args], options); + } + return execFileAsync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, options); +} + +function npmCommand(args) { + if (process.env.npm_execpath) { + return { command: process.execPath, args: [process.env.npm_execpath, ...args] }; + } + return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args }; +} + +function captureOutput(capture, chunk, label) { + if (capture.overflow) { + return; + } + const next = capture.value + chunk.toString(); + if (Buffer.byteLength(next) > maxCapturedBytes) { + capture.overflow = new Error(`${label} exceeded ${maxCapturedBytes} bytes`); + return; + } + capture.value = next; +} + +async function withDeadline(promise, label, timeoutMs = 30_000) { + let timeout; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +function commandEnvironment(cache) { + return { + ...process.env, + NO_COLOR: '1', + npm_config_cache: cache, + npm_config_fund: 'false', + npm_config_audit: 'false', + npm_config_update_notifier: 'false', + }; +} + +async function packWorkspace(workspace, destination, cache) { + const { stdout } = await execNpm( + ['pack', '--workspace', workspace, '--json', '--pack-destination', destination], + { cwd: root, env: commandEnvironment(cache), maxBuffer: 10 * 1024 * 1024 }, + ); + const jsonStart = stdout.lastIndexOf('\n['); + const results = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); + assert.equal(results.length, 1, `Expected one packed artifact for ${workspace}`); + return results[0]; +} + +function parseToolResult(result) { + assert.equal(result.content.length, 1); + assert.equal(result.content[0].type, 'text'); + return JSON.parse(result.content[0].text); +} + +async function exerciseRealClient(consumerDirectory, cache, expectedVersion) { + const npm = npmCommand(['exec', '--offline', '--', 'mcp']); + const transport = new StdioClientTransport({ + command: npm.command, + args: npm.args, + cwd: consumerDirectory, + env: commandEnvironment(cache), + stderr: 'pipe', + }); + const stderr = { value: '', overflow: null }; + transport.stderr?.on('data', (chunk) => { + captureOutput(stderr, chunk, 'Packed MCP stderr'); + }); + + const client = new Client({ name: 'buzzr-packed-artifact-test', version: '1.0.0' }); + try { + await withDeadline(client.connect(transport), 'Packed MCP initialization'); + assert.deepEqual(client.getServerVersion(), { name: 'buzzr', version: expectedVersion }); + + const listed = await withDeadline(client.listTools(), 'Packed MCP tools/list'); + const listedNames = new Set(listed.tools.map((tool) => tool.name)); + for (const toolName of coreToolNames) { + assert(listedNames.has(toolName), `Packed MCP is missing ${toolName}`); + } + + const dfs = parseToolResult( + await withDeadline( + client.callTool({ + name: 'grade_dfs_entry', + arguments: { + entryId: 'packed-proof', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Player One', + league: 'NBA', + propType: 'points', + line: 25.5, + direction: 'over', + actual: 31, + }, + { + legId: 'leg-2', + playerName: 'Player Two', + league: 'NBA', + propType: 'points', + line: 27.5, + direction: 'over', + actual: 33, + }, + ], + }, + }), + 'Packed MCP DFS call', + ), + ); + assert.equal(dfs.status, 'won'); + assert.equal(dfs.payout.total, 30); + assert.equal(dfs.validation.ok, true); + assert(Array.isArray(dfs.auditTrail)); + assert.match(dfs.explanation, /packed-proof settled as won/); + + const dfsBatch = parseToolResult( + await withDeadline( + client.callTool({ + name: 'grade_dfs_entries', + arguments: { + entries: [ + { + entryId: 'packed-batch-proof', + bookId: 'prizepicks', + playTypeId: 'power', + stake: 10, + displayedMultiplier: 3, + legs: [ + { + legId: 'leg-1', + playerName: 'Player One', + league: 'NBA', + propType: 'points', + line: 25.5, + direction: 'over', + actual: 31, + }, + { + legId: 'leg-2', + playerName: 'Player Two', + league: 'NBA', + propType: 'points', + line: 27.5, + direction: 'over', + actual: 33, + }, + ], + }, + ], + concurrency: 1, + }, + }), + 'Packed MCP DFS batch call', + ), + ); + assert.equal(dfsBatch.contractVersion, '1'); + assert.equal(dfsBatch.summary.settled, 1); + assert.equal(dfsBatch.results[0].entryId, 'packed-batch-proof'); + + const odds = parseToolResult( + await withDeadline( + client.callTool({ + name: 'fair_line', + arguments: { selected: -110, opposite: -110 }, + }), + 'Packed MCP odds call', + ), + ); + assert.equal(odds.fairProbability, 0.5); + + const closingLine = parseToolResult( + await withDeadline( + client.callTool({ + name: 'closing_line_value', + arguments: { placedAmericanOdds: 110, closingAmericanOdds: -105 }, + }), + 'Packed MCP closing line call', + ), + ); + assert.deepEqual(closingLine, { + contractVersion: '1', + clvPercent: 3.6, + beatClosingLine: true, + }); + + const betHistory = parseToolResult( + await withDeadline( + client.callTool({ + name: 'summarize_bet_history', + arguments: { + bets: [ + { + id: 'packed-bet', + userId: 'packed-user', + sportsbookSlug: 'draftkings', + kind: 'straight', + status: 'won', + stake: 10, + payout: 25, + placedAt: '2026-07-15T17:00:00.000Z', + settledAt: '2026-07-15T18:00:00.000Z', + }, + ], + period: 'day', + }, + }), + 'Packed MCP bet history call', + ), + ); + assert.equal(betHistory.contractVersion, '1'); + assert.equal(betHistory.period, 'day'); + assert.equal(betHistory.overall.totalBets, 1); + + const entertainment = parseToolResult( + await withDeadline( + client.callTool({ + name: 'predict_game_buzz', + arguments: { + league: 'NBA', + homeTeam: 'Lakers', + awayTeam: 'Celtics', + startsAt: '2030-07-17T19:30:00-04:00', + odds: { spread: -1.5, overUnder: 228.5 }, + narratives: { isRivalry: true, rivalryIntensity: 3 }, + }, + }), + 'Packed MCP entertainment call', + ), + ); + assert.equal(typeof entertainment.score, 'number'); + + const invalid = await withDeadline( + client.callTool({ + name: 'fair_line', + arguments: { selected: 0, opposite: -110 }, + }), + 'Packed MCP invalid call', + ); + assert.equal(invalid.isError, true); + assert.equal(invalid.content.length, 1); + assert.equal(invalid.content[0].type, 'text'); + assert.equal(parseToolResult(invalid).error.code, 'invalid_input'); + + const adversarialValidation = await withDeadline( + client.callTool({ + name: 'summarize_bet_history', + arguments: { bets: Array.from({ length: 5_000 }, () => ({})) }, + }), + 'Packed MCP adversarial validation call', + ); + assert.equal(adversarialValidation.isError, true); + assert.equal(parseToolResult(adversarialValidation).error.code, 'invalid_input'); + assert.ok( + Buffer.byteLength(JSON.stringify(adversarialValidation), 'utf8') < 65_536, + 'Packed MCP validation error must stay below 64 KiB', + ); + + const concurrent = await withDeadline( + Promise.all( + Array.from({ length: 12 }, (_, index) => + client.callTool({ + name: 'fair_line', + arguments: { + selected: -110 - index, + opposite: -110, + selectedSide: `call-${index}`, + }, + }), + ), + ), + 'Packed MCP concurrent calls', + ); + concurrent.forEach((result, index) => { + assert.equal(result.isError, undefined); + assert.equal(parseToolResult(result).selectedSide, `call-${index}`); + }); + + assert.equal(stderr.overflow, null); + return { toolCount: listed.tools.length, stderr: () => stderr.value }; + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + } +} + +async function exerciseMalformedInput(consumerDirectory, cache, cliPath) { + const child = spawn(process.execPath, [cliPath], { + cwd: consumerDirectory, + env: commandEnvironment(cache), + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = { value: '', overflow: null }; + const stderr = { value: '', overflow: null }; + child.stdout.on('data', (chunk) => { + captureOutput(stdout, chunk, 'Packed MCP stdout'); + }); + child.stderr.on('data', (chunk) => { + captureOutput(stderr, chunk, 'Packed MCP stderr'); + }); + + const closed = new Promise((resolveClose, rejectClose) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + rejectClose(new Error('Packed MCP did not shut down after stdin closed')); + }, 5_000); + child.once('error', rejectClose); + child.once('close', (code, signal) => { + clearTimeout(timeout); + resolveClose({ code, signal }); + }); + }); + + const initialize = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'buzzr-malformed-input-test', version: '1.0.0' }, + }, + }); + const initialized = JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + params: {}, + }); + const listTools = JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }); + const splitAt = Math.floor(initialize.length / 2); + + child.stdin.write( + `not-json\n${JSON.stringify({ jsonrpc: '2.0', id: 0, method: 42 })}\n${initialize.slice(0, splitAt)}`, + ); + setImmediate(() => { + child.stdin.end(`${initialize.slice(splitAt)}\r\n${initialized}\n${listTools}\n{"jsonrpc":`); + }); + + const exit = await closed; + assert.deepEqual(exit, { code: 0, signal: null }); + assert.equal(stdout.overflow, null); + assert.equal(stderr.overflow, null); + + assert(stdout.value.endsWith('\n'), 'Packed MCP stdout must end on a frame boundary'); + const lines = stdout.value.slice(0, -1).split('\n'); + assert.equal(lines.length, 2, `Expected two JSON-RPC responses, received: ${stdout.value}`); + assert( + lines.every((line) => line.length > 0), + 'Packed MCP stdout contained a blank frame', + ); + const responses = lines.map((line) => JSONRPCMessageSchema.parse(JSON.parse(line))); + assert.deepEqual( + responses.map((response) => response.id), + [1, 2], + ); + assert.equal(responses[0].result.serverInfo.name, 'buzzr'); + assert(Array.isArray(responses[1].result.tools)); + assert.match(stderr.value, /buzzr MCP server v\S+ listening on stdio/); +} + +async function exerciseOversizedInput(consumerDirectory, cache, cliPath) { + const child = spawn(process.execPath, [cliPath], { + cwd: consumerDirectory, + env: commandEnvironment(cache), + stdio: ['pipe', 'pipe', 'pipe'], + }); + const stdout = { value: '', overflow: null }; + const stderr = { value: '', overflow: null }; + child.stdout.on('data', (chunk) => { + captureOutput(stdout, chunk, 'Oversized-input MCP stdout'); + }); + child.stderr.on('data', (chunk) => { + captureOutput(stderr, chunk, 'Oversized-input MCP stderr'); + }); + + const closed = new Promise((resolveClose, rejectClose) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + rejectClose(new Error('Packed MCP did not reject oversized stdin')); + }, 5_000); + child.once('error', rejectClose); + child.once('close', (code, signal) => { + clearTimeout(timeout); + resolveClose({ code, signal }); + }); + }); + + // Keep the client side of stdin open after the oversized frame. The server + // must reject and exit on its own instead of waiting forever for client EOF. + child.stdin.write(Buffer.concat([Buffer.alloc(2 * 1_024 * 1_024 + 1, 0x78), Buffer.from('\n')])); + + const exit = await closed; + assert.deepEqual(exit, { code: 1, signal: null }); + assert.equal(stdout.overflow, null); + assert.equal(stderr.overflow, null); + assert.equal(stdout.value, ''); + assert.match(stderr.value, /buzzr-mcp rejected oversized input\./); + assert.doesNotMatch(stderr.value, /RangeError|ERR_|frame length|2 MiB/i); +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), 'buzzr-mcp-packed-')); +const packsDirectory = join(temporaryRoot, 'packs'); +const consumerDirectory = join(temporaryRoot, 'consumer'); +const cache = join(temporaryRoot, 'npm-cache'); + +try { + await mkdir(packsDirectory, { recursive: true }); + await mkdir(consumerDirectory, { recursive: true }); + await writeFile( + join(consumerDirectory, 'package.json'), + `${JSON.stringify({ name: 'buzzr-mcp-packed-consumer', private: true }, null, 2)}\n`, + ); + + const packed = []; + for (const workspace of workspaces) { + packed.push(await packWorkspace(workspace, packsDirectory, cache)); + } + const mcpPack = packed.find((artifact) => artifact.name === '@buzzr/mcp'); + assert(mcpPack, 'Missing packed @buzzr/mcp artifact'); + const cliFile = mcpPack.files.find((file) => file.path === 'dist/cli.js'); + assert(cliFile, 'Packed @buzzr/mcp is missing dist/cli.js'); + if (process.platform !== 'win32') { + assert.notEqual(cliFile.mode & 0o111, 0, 'Packed MCP CLI is not executable'); + } + + const tarballs = packed.map((artifact) => join(packsDirectory, artifact.filename)); + await execNpm(['install', '--ignore-scripts', '--no-audit', '--no-fund', ...tarballs], { + cwd: consumerDirectory, + env: commandEnvironment(cache), + maxBuffer: 20 * 1024 * 1024, + }); + + const installedPackagePath = join(consumerDirectory, 'node_modules', '@buzzr', 'mcp'); + const installedManifest = JSON.parse( + await readFile(join(installedPackagePath, 'package.json'), 'utf8'), + ); + assert.equal(installedManifest.scripts.prepack, 'npm run build'); + assert.equal(installedManifest.bin.mcp, './dist/cli.js'); + assert.equal(installedManifest.bin['buzzr-mcp'], './dist/cli.js'); + + const executableSuffix = process.platform === 'win32' ? '.cmd' : ''; + const executableMode = process.platform === 'win32' ? undefined : fsConstants.X_OK; + await access( + join(consumerDirectory, 'node_modules', '.bin', `mcp${executableSuffix}`), + executableMode, + ); + await access( + join(consumerDirectory, 'node_modules', '.bin', `buzzr-mcp${executableSuffix}`), + executableMode, + ); + + const protocol = await exerciseRealClient(consumerDirectory, cache, installedManifest.version); + assert.match(protocol.stderr(), /buzzr MCP server v\S+ listening on stdio/); + await exerciseMalformedInput( + consumerDirectory, + cache, + join(installedPackagePath, 'dist', 'cli.js'), + ); + await exerciseOversizedInput( + consumerDirectory, + cache, + join(installedPackagePath, 'dist', 'cli.js'), + ); + + process.stdout.write( + `@buzzr/mcp packed artifact passed: ${protocol.toolCount} tools, real stdio client, schema rejection, concurrency, malformed-input recovery, stdout purity, and clean shutdown\n`, + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} diff --git a/scripts/test-release-artifacts.mjs b/scripts/test-release-artifacts.mjs new file mode 100644 index 0000000..4149c05 --- /dev/null +++ b/scripts/test-release-artifacts.mjs @@ -0,0 +1,282 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packages = [ + { name: '@buzzr/bets-core', exportName: 'normalizeSportsbookSlug' }, + { name: '@buzzr/dfs-cli', exportName: 'runGradeFromFiles', cjs: false }, + { name: '@buzzr/dfs-engine', exportName: 'createDfsEngine' }, + { name: '@buzzr/dfs-engine-test-vectors', exportName: 'TEST_VECTORS' }, + { name: '@buzzr/dfs-provider-espn', exportName: 'createEspnStatProvider' }, + { + name: '@buzzr/dfs-provider-sportradar', + exportName: 'createSportradarStatProvider', + }, + { name: '@buzzr/dfs-react', exportName: 'getSlipDisplayModel' }, + { name: '@buzzr/dfs-testkit', exportName: 'makeDfsEntry' }, + { name: '@buzzr/entertainment-engine', exportName: 'resolveBuzzScores' }, + { name: '@buzzr/mcp', exportName: 'createBuzzrMcpServer', cjs: false }, +]; + +function npmCommand(args) { + if (process.env.npm_execpath) { + return { command: process.execPath, args: [process.env.npm_execpath, ...args] }; + } + return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args }; +} + +async function execNpm(args, options = {}) { + const command = npmCommand(args); + return execFileAsync(command.command, command.args, { + cwd: root, + maxBuffer: 40 * 1_024 * 1_024, + ...options, + }); +} + +function commandEnvironment(paths) { + return { + ...process.env, + HOME: paths.home, + USERPROFILE: paths.home, + APPDATA: paths.appData, + LOCALAPPDATA: paths.localAppData, + TEMP: paths.temp, + TMP: paths.temp, + TMPDIR: paths.temp, + NO_COLOR: '1', + npm_config_cache: paths.cache, + npm_config_userconfig: paths.userConfig, + npm_config_globalconfig: paths.globalConfig, + npm_config_registry: 'https://registry.npmjs.org/', + npm_config_fund: 'false', + npm_config_audit: 'false', + npm_config_update_notifier: 'false', + }; +} + +async function packWorkspace(packageName, destination, environment) { + const { stdout } = await execNpm( + ['pack', '--workspace', packageName, '--json', '--pack-destination', destination], + { env: environment }, + ); + const jsonStart = stdout.lastIndexOf('\n['); + const result = JSON.parse(jsonStart === -1 ? stdout : stdout.slice(jsonStart + 1)); + assert.equal(result.length, 1, `Expected one packed artifact for ${packageName}`); + assert.equal(result[0].name, packageName, `Packed the wrong workspace for ${packageName}`); + assert( + result[0].files.some((file) => file.path === 'package.json'), + `${packageName} tarball is missing package.json`, + ); + assert( + result[0].files.some((file) => file.path === 'dist/index.js'), + `${packageName} tarball is missing dist/index.js`, + ); + return result[0]; +} + +function packageDirectory(consumerDirectory, packageName) { + return join(consumerDirectory, 'node_modules', ...packageName.split('/')); +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), 'buzzr-release-artifacts-')); +const paths = { + home: join(temporaryRoot, 'home'), + appData: join(temporaryRoot, 'app-data'), + localAppData: join(temporaryRoot, 'local-app-data'), + temp: join(temporaryRoot, 'temp'), + cache: join(temporaryRoot, 'npm-cache'), + userConfig: join(temporaryRoot, 'home', '.npmrc'), + globalConfig: join(temporaryRoot, 'home', 'global.npmrc'), +}; +const packsDirectory = join(temporaryRoot, 'packs'); +const consumerDirectory = join(temporaryRoot, 'consumer'); +const environment = commandEnvironment(paths); + +try { + for (const directory of [ + paths.home, + paths.appData, + paths.localAppData, + paths.temp, + paths.cache, + packsDirectory, + consumerDirectory, + ]) { + await mkdir(directory, { recursive: true }); + } + await Promise.all([ + writeFile(paths.userConfig, 'registry=https://registry.npmjs.org/\n'), + writeFile(paths.globalConfig, 'registry=https://registry.npmjs.org/\n'), + writeFile( + join(consumerDirectory, 'package.json'), + `${JSON.stringify({ name: 'buzzr-packed-release-consumer', private: true }, null, 2)}\n`, + ), + ]); + + await execNpm(['run', 'build'], { env: environment }); + + const packed = []; + for (const packageDefinition of packages) { + packed.push(await packWorkspace(packageDefinition.name, packsDirectory, environment)); + } + + const tarballs = packed.map((artifact) => join(packsDirectory, artifact.filename)); + await execNpm( + ['install', '--ignore-scripts', '--package-lock=false', '--no-audit', '--no-fund', ...tarballs], + { cwd: consumerDirectory, env: environment }, + ); + await execNpm(['ls', '--all'], { cwd: consumerDirectory, env: environment }); + + const expectedVersions = {}; + for (const artifact of packed) { + const installedDirectory = packageDirectory(consumerDirectory, artifact.name); + assert.equal( + (await lstat(installedDirectory)).isSymbolicLink(), + false, + `${artifact.name} resolved to a workspace link instead of the packed artifact`, + ); + const manifest = JSON.parse(await readFile(join(installedDirectory, 'package.json'), 'utf8')); + assert.equal(manifest.name, artifact.name); + assert.equal(manifest.version, artifact.version); + expectedVersions[artifact.name] = artifact.version; + } + + const verifierPath = join(consumerDirectory, 'verify-packed-imports.mjs'); + await writeFile( + verifierPath, + `import assert from 'node:assert/strict';\n` + + `import { createRequire } from 'node:module';\n` + + `const require = createRequire(import.meta.url);\n` + + `const probes = ${JSON.stringify(packages)};\n` + + `const expectedVersions = ${JSON.stringify(expectedVersions)};\n` + + `for (const probe of probes) {\n` + + ` const loaded = await import(probe.name);\n` + + ` assert.notEqual(loaded[probe.exportName], undefined, probe.name + ' missing ' + probe.exportName);\n` + + ` if (probe.cjs !== false) {\n` + + ` const required = require(probe.name);\n` + + ` assert.notEqual(required[probe.exportName], undefined, probe.name + ' CJS missing ' + probe.exportName);\n` + + ` }\n` + + `}\n` + + `const enginePackage = await import('@buzzr/dfs-engine');\n` + + `const vectors = await import('@buzzr/dfs-engine-test-vectors');\n` + + `for (const vector of vectors.TEST_VECTORS) {\n` + + ` const provider = enginePackage.defineStatProvider({\n` + + ` id: 'packed-vector-provider',\n` + + ` getGameLog: ({ leg }) => vector.gameLogsByLegId[leg.legId] ?? [],\n` + + ` });\n` + + ` const engine = enginePackage.createDfsEngine({ statProviders: [provider] });\n` + + ` const result = await engine.settleEntry(vector.entry, {\n` + + ` statProviderId: provider.id,\n` + + ` settledAt: '2026-07-16T12:00:00.000Z',\n` + + ` });\n` + + ` assert.equal(result.status, vector.expected.status, vector.name + ' status drifted');\n` + + `}\n` + + `const testkit = await import('@buzzr/dfs-testkit');\n` + + `const fixtureEntry = testkit.makeDfsEntry({\n` + + ` legs: [\n` + + ` testkit.makeDfsLeg({ legId: 'packed-leg-1', actual: 31 }),\n` + + ` testkit.makeDfsLeg({ legId: 'packed-leg-2', actual: 32 }),\n` + + ` ],\n` + + `});\n` + + `const fixtureResult = await enginePackage.createDfsEngine().settleEntry(fixtureEntry);\n` + + `assert.equal(fixtureResult.status, 'won');\n` + + `const espn = await import('@buzzr/dfs-provider-espn');\n` + + `assert.equal(espn.createEspnStatProvider({ getGameLog: () => [] }).id, 'espn');\n` + + `const sportradar = await import('@buzzr/dfs-provider-sportradar');\n` + + `assert.equal(sportradar.createSportradarStatProvider({ getGameLog: () => [] }).id, 'sportradar');\n` + + `assert.equal(sportradar.sportradarRowToGameLog({ points: 12 }).points, '12');\n` + + `const react = await import('@buzzr/dfs-react');\n` + + `assert.equal(react.getSlipDisplayModel(fixtureResult).tone, 'win');\n` + + `const mcp = await import('@buzzr/mcp');\n` + + `assert.equal(mcp.allTools.length, 11);\n` + + `assert(vectors.TEST_VECTORS.length >= 11);\n` + + `assert.equal(Object.keys(expectedVersions).length, probes.length);\n`, + ); + await execFileAsync(process.execPath, [verifierPath], { + cwd: consumerDirectory, + env: environment, + maxBuffer: 10 * 1_024 * 1_024, + }); + + const typeConsumerPath = join(consumerDirectory, 'typecheck-consumer.ts'); + const typeConfigPath = join(consumerDirectory, 'tsconfig.json'); + await Promise.all([ + writeFile( + typeConsumerPath, + `import { createDfsEngine, type DfsSettlementResult } from '@buzzr/dfs-engine';\n` + + `import { TEST_VECTORS } from '@buzzr/dfs-engine-test-vectors';\n` + + `import { createEspnStatProvider } from '@buzzr/dfs-provider-espn';\n` + + `import { createSportradarStatProvider } from '@buzzr/dfs-provider-sportradar';\n` + + `import { getSlipDisplayModel } from '@buzzr/dfs-react';\n` + + `import { makeDfsEntry } from '@buzzr/dfs-testkit';\n` + + `import { runGradeFromFiles } from '@buzzr/dfs-cli';\n` + + `import { normalizeSportsbookSlug } from '@buzzr/bets-core';\n` + + `import { resolveBuzzScores } from '@buzzr/entertainment-engine';\n` + + `import { createBuzzrMcpServer } from '@buzzr/mcp';\n` + + `const engine = createDfsEngine();\n` + + `engine.getBookPolicies();\n` + + `const result = null as DfsSettlementResult | null;\n` + + `void [TEST_VECTORS, createEspnStatProvider, createSportradarStatProvider, getSlipDisplayModel, makeDfsEntry, runGradeFromFiles, normalizeSportsbookSlug, resolveBuzzScores, createBuzzrMcpServer, result];\n`, + ), + writeFile( + typeConfigPath, + `${JSON.stringify( + { + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2022', + strict: true, + noEmit: true, + skipLibCheck: false, + types: [], + }, + files: ['./typecheck-consumer.ts'], + }, + null, + 2, + )}\n`, + ), + ]); + await execFileAsync( + process.execPath, + [resolve(root, 'node_modules/typescript/bin/tsc'), '-p', typeConfigPath], + { + cwd: consumerDirectory, + env: environment, + maxBuffer: 10 * 1_024 * 1_024, + }, + ); + + const executableSuffix = process.platform === 'win32' ? '.cmd' : ''; + const executableMode = process.platform === 'win32' ? undefined : fsConstants.X_OK; + for (const executable of ['dfs-grade', 'mcp', 'buzzr-mcp']) { + await access( + join(consumerDirectory, 'node_modules', '.bin', `${executable}${executableSuffix}`), + executableMode, + ); + } + const cli = await execNpm(['exec', '--offline', '--', 'dfs-grade', '--help'], { + cwd: consumerDirectory, + env: environment, + }); + assert.match(cli.stdout, /dfs-grade /); + + process.stdout.write( + `Packed release ecosystem passed: ${packed.length} clean-installed packages, ESM/CJS/types, vector replay, provider/testkit/react integration, three bins, and 11 MCP tools.\n`, + ); +} finally { + if (process.env.KEEP_BUZZR_PACKED_ARTIFACTS === '1') { + process.stderr.write(`Kept packed release proof at ${temporaryRoot}\n`); + } else { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/tests/capture-adoption-metrics.test.mjs b/scripts/tests/capture-adoption-metrics.test.mjs new file mode 100644 index 0000000..7da6f58 --- /dev/null +++ b/scripts/tests/capture-adoption-metrics.test.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + MCP_REGISTRY_NAME, + NPM_PACKAGES, + captureAdoptionMetrics, + parseArguments, + validateCompleteUtcWindow, +} from '../capture-adoption-metrics.mjs'; + +const baselineDownloads = new Map([ + ['@buzzr/bets-core', 34], + ['@buzzr/dfs-cli', 9], + ['@buzzr/dfs-engine', 38], + ['@buzzr/dfs-engine-test-vectors', 16], + ['@buzzr/dfs-provider-espn', 25], + ['@buzzr/dfs-provider-sportradar', 18], + ['@buzzr/dfs-react', 8], + ['@buzzr/dfs-testkit', 19], + ['@buzzr/entertainment-engine', 20], + ['@buzzr/mcp', 4], +]); + +const jsonResponse = (value, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { 'content-type': 'application/json' }, + }); + +function createFetchFixture() { + const requested = []; + + const fetch = async (input, init = {}) => { + const url = new URL(input); + requested.push({ url: url.href, headers: new Headers(init.headers) }); + + if (url.hostname === 'api.npmjs.org') { + const packageName = decodeURIComponent(url.pathname.split('/').at(-1)); + assert.equal(url.pathname.split('/').at(-2), '2026-07-09:2026-07-15'); + return jsonResponse({ + start: '2026-07-09', + end: '2026-07-15', + package: packageName, + downloads: baselineDownloads.get(packageName), + }); + } + + if (url.hostname === 'api.github.com') { + assert.equal(new Headers(init.headers).get('authorization'), 'Bearer test-token'); + + if (url.pathname === '/repos/Buzzr-app/dfs-engine') { + return jsonResponse({ + stargazers_count: 1, + forks_count: 0, + subscribers_count: 0, + html_url: 'https://github.com/Buzzr-app/dfs-engine', + }); + } + if (url.pathname.endsWith('/traffic/clones')) { + return jsonResponse({ + count: 98, + uniques: 37, + clones: [{ timestamp: '2026-07-15T00:00:00Z', count: 2, uniques: 2 }], + }); + } + if (url.pathname.endsWith('/traffic/views')) { + return jsonResponse({ + count: 1, + uniques: 1, + views: [{ timestamp: '2026-07-15T00:00:00Z', count: 1, uniques: 1 }], + }); + } + if (url.pathname.endsWith('/traffic/popular/referrers')) { + return jsonResponse([{ referrer: 'github.com', count: 1, uniques: 1 }]); + } + if (url.pathname.endsWith('/traffic/popular/paths')) { + return jsonResponse([ + { path: '/Buzzr-app/dfs-engine', title: 'Buzzr DFS engine', count: 1, uniques: 1 }, + ]); + } + if (url.pathname.endsWith('/issues') && url.searchParams.get('state') === 'open') { + return jsonResponse([ + { number: 11, created_at: '2026-06-01T00:00:00Z', state: 'open' }, + { + number: 12, + created_at: '2026-06-02T00:00:00Z', + state: 'open', + pull_request: { url: 'https://api.github.com/repos/Buzzr-app/dfs-engine/pulls/12' }, + }, + ]); + } + if (url.pathname.endsWith('/issues') && url.searchParams.get('state') === 'all') { + return jsonResponse([ + { number: 13, created_at: '2026-07-10T12:00:00Z', state: 'closed' }, + { + number: 14, + created_at: '2026-07-11T12:00:00Z', + state: 'closed', + pull_request: { url: 'https://api.github.com/repos/Buzzr-app/dfs-engine/pulls/14' }, + }, + { number: 15, created_at: '2026-07-16T00:00:00Z', state: 'open' }, + ]); + } + } + + if (url.hostname === 'registry.modelcontextprotocol.io') { + assert.equal(url.pathname, '/v0.1/servers'); + assert.equal(url.searchParams.get('search'), MCP_REGISTRY_NAME); + assert.equal(url.searchParams.get('version'), 'latest'); + return jsonResponse({ + servers: [ + { + server: { name: MCP_REGISTRY_NAME, version: '5.1.0', title: 'Buzzr Sports Engine' }, + _meta: { + 'io.modelcontextprotocol.registry/official': { + status: 'active', + publishedAt: '2026-07-16T14:00:00Z', + isLatest: true, + }, + }, + }, + { + server: { name: `${MCP_REGISTRY_NAME}-similar`, version: '1.0.0' }, + _meta: { + 'io.modelcontextprotocol.registry/official': { + status: 'active', + publishedAt: '2026-07-15T14:00:00Z', + isLatest: true, + }, + }, + }, + ], + metadata: { count: 2 }, + }); + } + + return jsonResponse({ message: `Unexpected request: ${url.href}` }, 404); + }; + + return { fetch, requested }; +} + +test('tracks the complete ten-package Buzzr public family', () => { + assert.deepEqual(NPM_PACKAGES, [...baselineDownloads.keys()]); +}); + +test('requires explicit, completed UTC date windows', () => { + const now = new Date('2026-07-16T12:00:00Z'); + + assert.deepEqual(validateCompleteUtcWindow('2026-07-09', '2026-07-15', now), { + startDate: '2026-07-09', + endDate: '2026-07-15', + timezone: 'UTC', + inclusivity: 'start and end dates inclusive', + completeDaysOnly: true, + }); + assert.throws( + () => validateCompleteUtcWindow('2026-07-09', '2026-07-16', now), + /must be earlier than the current UTC date/, + ); + assert.throws( + () => validateCompleteUtcWindow('2026-07-15', '2026-07-09', now), + /must not be after/, + ); + assert.throws(() => validateCompleteUtcWindow('07-09-2026', '2026-07-15', now), /YYYY-MM-DD/); + assert.throws(() => validateCompleteUtcWindow('2026-02-30', '2026-07-15', now), /real UTC date/); +}); + +test('requires both CLI window boundaries', () => { + assert.deepEqual(parseArguments(['--start', '2026-07-09', '--end', '2026-07-15']), { + startDate: '2026-07-09', + endDate: '2026-07-15', + }); + assert.throws(() => parseArguments(['--start', '2026-07-09']), /--start and --end are required/); + assert.throws(() => parseArguments(['--start', '--end', '2026-07-15']), /requires a YYYY-MM-DD/); + assert.throws(() => parseArguments(['--start', '2026-07-09', '--wat']), /Unknown argument/); +}); + +test('rejects missing runtime dependencies and invalid capture timestamps', async () => { + await assert.rejects( + captureAdoptionMetrics({ + startDate: '2026-07-09', + endDate: '2026-07-15', + capturedAt: 'not-a-date', + githubToken: 'test-token', + fetch: async () => jsonResponse({}), + }), + /capturedAt|Invalid time value/, + ); + await assert.rejects( + captureAdoptionMetrics({ + startDate: '2026-07-09', + endDate: '2026-07-15', + capturedAt: '2026-07-16T15:00:00.000Z', + githubToken: '', + fetch: async () => jsonResponse({}), + }), + /GitHub traffic requires/, + ); + await assert.rejects( + captureAdoptionMetrics({ + startDate: '2026-07-09', + endDate: '2026-07-15', + capturedAt: '2026-07-16T15:00:00.000Z', + githubToken: 'test-token', + fetch: null, + }), + /Fetch API implementation is required/, + ); +}); + +test('captures source-backed adoption without claiming unique users', async () => { + const fixture = createFetchFixture(); + const snapshot = await captureAdoptionMetrics({ + startDate: '2026-07-09', + endDate: '2026-07-15', + capturedAt: '2026-07-16T15:00:00.000Z', + githubToken: 'test-token', + fetch: fixture.fetch, + }); + + assert.equal(snapshot.schemaVersion, 1); + assert.equal(snapshot.capturedAt, '2026-07-16T15:00:00.000Z'); + assert.equal(snapshot.repository, 'Buzzr-app/dfs-engine'); + assert.equal(snapshot.npm.packages.length, 10); + assert.equal(snapshot.npm.familyTotal, 191); + assert.equal(snapshot.npm.interpretation.uniqueUsers, false); + assert.match(snapshot.npm.source.api, /^https:\/\/api\.npmjs\.org\//); + assert.equal(snapshot.npm.source.window.completeDaysOnly, true); + + assert.equal(snapshot.github.repository.stars, 1); + assert.equal(snapshot.github.repository.forks, 0); + assert.equal(snapshot.github.repository.subscribers, 0); + assert.equal(snapshot.github.traffic.clones, 98); + assert.equal(snapshot.github.traffic.uniqueCloners, 37); + assert.equal(snapshot.github.traffic.views, 1); + assert.equal(snapshot.github.traffic.uniqueViewers, 1); + assert.deepEqual(snapshot.github.traffic.topReferrers, [ + { referrer: 'github.com', count: 1, uniques: 1 }, + ]); + assert.equal(snapshot.github.traffic.topPaths[0].path, '/Buzzr-app/dfs-engine'); + assert.match(snapshot.github.source.trafficWindow, /rolling 14-day/i); + assert.equal(snapshot.github.issues.openAtCapture, 1); + assert.equal(snapshot.github.issues.openedInMeasurementWindow, 1); + assert.equal(snapshot.github.issues.excludesPullRequests, true); + + assert.equal(snapshot.mcpRegistry.exactName, MCP_REGISTRY_NAME); + assert.equal(snapshot.mcpRegistry.exactRecordCount, 1); + assert.equal(snapshot.mcpRegistry.records[0].version, '5.1.0'); + assert.equal(snapshot.mcpAdoptionProxy.package, '@buzzr/mcp'); + assert.equal(snapshot.mcpAdoptionProxy.downloads, 4); + assert.equal(snapshot.mcpAdoptionProxy.uniqueUsers, false); + assert.match(snapshot.mcpAdoptionProxy.rationale, /privacy-preserving/i); + + const githubRequests = fixture.requested.filter(({ url }) => + url.startsWith('https://api.github.com/'), + ); + assert(githubRequests.length >= 7); + assert( + githubRequests.every(({ headers }) => headers.get('authorization') === 'Bearer test-token'), + ); +}); + +test('fails closed when an upstream response is not successful', async () => { + await assert.rejects( + captureAdoptionMetrics({ + startDate: '2026-07-09', + endDate: '2026-07-15', + capturedAt: '2026-07-16T15:00:00.000Z', + githubToken: 'test-token', + fetch: async () => jsonResponse({ message: 'rate limited' }, 429), + }), + /429.*rate limited/, + ); + + await assert.rejects( + captureAdoptionMetrics({ + startDate: '2026-07-09', + endDate: '2026-07-15', + capturedAt: '2026-07-16T15:00:00.000Z', + githubToken: 'test-token', + fetch: async () => new Response('not json', { status: 200 }), + }), + /returned non-JSON data/, + ); +}); + +test('documents repeatable windows and keeps the network capture out of verify', async () => { + const [baseline, checklist, manifest] = await Promise.all([ + readFile(new URL('../../docs/launch/adoption-baseline-2026-07-16.md', import.meta.url), 'utf8'), + readFile(new URL('../../docs/launch/launch-checklist.md', import.meta.url), 'utf8'), + readFile(new URL('../../package.json', import.meta.url), 'utf8').then(JSON.parse), + ]); + + assert.match(baseline, /release day \(day 0\)/i); + assert.match(baseline, /D\+1 through D\+7/); + assert.match(baseline, /D\+1 through D\+30/); + assert.match(baseline, /privacy-preserving MCP adoption proxy/i); + assert.match(baseline, /npm run --silent capture:adoption/); + assert.match(baseline, /GitHub Pages has no first-party analytics configured/i); + assert.match(baseline, /referrer.*attribution/i); + assert.doesNotMatch(checklist, /Docs site uniques\s*\|\s*1/i); + assert.match(checklist, /GitHub repo page views \(trailing 14 days\)/i); + assert.equal(manifest.scripts['capture:adoption'], 'node scripts/capture-adoption-metrics.mjs'); + assert.equal( + manifest.scripts['test:adoption-capture'], + 'node --test scripts/tests/capture-adoption-metrics.test.mjs', + ); + assert.doesNotMatch(manifest.scripts.verify, /capture:adoption/); +}); diff --git a/scripts/validate-buzzr-skill.mjs b/scripts/validate-buzzr-skill.mjs new file mode 100644 index 0000000..23432ab --- /dev/null +++ b/scripts/validate-buzzr-skill.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, lstat, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const skillName = 'buzzr-sports-engine'; +const sourceSkill = resolve(root, 'skills', skillName); +const officialValidator = resolve(root, 'scripts/vendor/openai-skill-creator/quick_validate.py'); +const expectedValidatorHash = '6cc9dc3199c935916cf6f73fcbbbb0e3bb1b58c8f5109fefa499978908164f51'; + +function commandResult(command, args, options = {}) { + return spawnSync(command, args, { + cwd: root, + encoding: 'utf8', + env: { ...process.env, CI: '1', DO_NOT_TRACK: '1', NO_COLOR: '1' }, + ...options, + }); +} + +function findPython() { + const candidates = process.env.PYTHON + ? [[process.env.PYTHON, []]] + : process.platform === 'win32' + ? [ + ['py', ['-3']], + ['python', []], + ['python3', []], + ] + : [ + ['python3', []], + ['python', []], + ]; + for (const [command, prefix] of candidates) { + if (commandResult(command, [...prefix, '-c', 'import yaml']).status === 0) { + return { command, prefix }; + } + } + throw new Error('Python with PyYAML is required to run the official skill validator.'); +} + +const validatorBytes = await readFile(officialValidator); +assert.equal( + createHash('sha256').update(validatorBytes).digest('hex'), + expectedValidatorHash, + 'Vendored quick_validate.py must match the pinned official OpenAI skill-creator validator.', +); + +const python = findPython(); +const validation = commandResult(python.command, [ + ...python.prefix, + officialValidator, + sourceSkill, +]); +assert.equal( + validation.status, + 0, + `Official quick_validate.py failed:\n${validation.stdout}${validation.stderr}`, +); +assert.match(validation.stdout, /Skill is valid!/); + +const skillsManifest = JSON.parse( + await readFile(resolve(root, 'node_modules/skills/package.json'), 'utf8'), +); +assert.equal(skillsManifest.version, '1.5.17'); +const skillsCli = resolve(root, 'node_modules/skills', skillsManifest.bin.skills); +await access(skillsCli); + +const temporaryProject = await mkdtemp(join(tmpdir(), 'buzzr-skill-proof-')); +try { + await writeFile( + join(temporaryProject, 'package.json'), + `${JSON.stringify({ name: 'buzzr-skill-proof', private: true }, null, 2)}\n`, + ); + const install = commandResult( + process.execPath, + [skillsCli, 'add', root, '--skill', skillName, '--agent', 'codex', '--yes', '--copy'], + { cwd: temporaryProject }, + ); + assert.equal( + install.status, + 0, + `Isolated one-command skill install failed:\n${install.stdout}${install.stderr}`, + ); + + const installedSkill = resolve(temporaryProject, '.agents/skills', skillName); + const files = [ + 'SKILL.md', + 'agents/openai.yaml', + 'references/mcp-tools.md', + 'references/operator-safety.md', + ]; + for (const path of files) { + assert.equal( + await readFile(resolve(installedSkill, path), 'utf8'), + await readFile(resolve(sourceSkill, path), 'utf8'), + `Installed ${path} must match the repository source byte-for-byte.`, + ); + assert.equal((await lstat(resolve(installedSkill, path))).isSymbolicLink(), false); + } + + const lock = JSON.parse(await readFile(resolve(temporaryProject, 'skills-lock.json'), 'utf8')); + assert.equal(lock.version, 1); + assert.equal(lock.skills?.[skillName]?.sourceType, 'local'); + assert.equal(resolve(lock.skills[skillName].source), resolve(root)); + assert.match(lock.skills[skillName].computedHash, /^[a-f0-9]{64}$/); +} finally { + await rm(temporaryProject, { recursive: true, force: true }); +} + +process.stdout.write( + 'Buzzr skill passed the pinned official quick_validate.py and isolated skills@1.5.17 local install proof.\n', +); diff --git a/scripts/validate-mcp-examples.mjs b/scripts/validate-mcp-examples.mjs new file mode 100644 index 0000000..ce864f6 --- /dev/null +++ b/scripts/validate-mcp-examples.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + +import { + SERVER_NAME, + SERVER_VERSION, + allTools, + createBuzzrMcpServer, +} from '../packages/mcp/dist/index.js'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const examples = JSON.parse(await readFile(`${root}packages/mcp/examples/mcp-calls.json`, 'utf8')); +const packageManifest = JSON.parse(await readFile(`${root}packages/mcp/package.json`, 'utf8')); +const skill = await readFile(`${root}skills/buzzr-sports-engine/SKILL.md`, 'utf8'); + +function pathValue(value, path) { + return path.split('.').reduce((current, segment) => current?.[segment], value); +} + +function assertExpected(actual, expected, context) { + for (const [path, value] of Object.entries(expected)) { + assert.deepEqual(pathValue(actual, path), value, `${context} did not match ${path}`); + } +} + +function parseToolResult(result, toolName) { + assert.equal(result.isError, undefined, `${toolName} returned an MCP tool error`); + assert.equal(result.content?.length, 1, `${toolName} must return one content item`); + assert.equal(result.content[0]?.type, 'text', `${toolName} must return text content`); + return JSON.parse(result.content[0].text); +} + +assert.equal(examples.schemaVersion, '1'); +assert.equal(examples.transport, 'stdio'); +assert.equal(examples.discovery.initialize.request.method, 'initialize'); +assert.equal(examples.discovery.initialized.method, 'notifications/initialized'); +assert.equal(examples.discovery.toolsList.request.method, 'tools/list'); +assert.equal(examples.discovery.initialize.expect.serverInfo.version, '$PACKAGE_VERSION'); +assert.equal(SERVER_VERSION, packageManifest.version); + +const expectedToolNames = examples.discovery.toolsList.expect.toolNames; +assert.deepEqual( + allTools.map((tool) => tool.name), + expectedToolNames, +); + +const workflow = examples.workflows.find(({ id }) => id === 'safe-single-entry-settlement'); +assert.ok(workflow, 'The safe single-entry settlement example is required.'); +const workflowToolNames = workflow.calls.map((call) => call.request.params.name); +assert.deepEqual(workflowToolNames, [ + 'list_book_policies', + 'validate_dfs_entry', + 'grade_dfs_entry', +]); + +let previousSkillIndex = -1; +for (const toolName of workflowToolNames) { + const index = skill.indexOf(`\`${toolName}\``, previousSkillIndex + 1); + assert(index > previousSkillIndex, `SKILL.md must route ${toolName} in safe workflow order.`); + previousSkillIndex = index; +} + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +const client = new Client({ name: 'buzzr-example-validator', version: '1.0.0' }); +const server = createBuzzrMcpServer(); + +try { + await server.connect(serverTransport); + await client.connect(clientTransport); + + assert.deepEqual(client.getServerVersion(), { + name: examples.discovery.initialize.expect.serverInfo.name, + version: SERVER_VERSION, + }); + assert.deepEqual( + client.getServerCapabilities()?.tools, + examples.discovery.initialize.expect.capabilities.tools, + ); + + const listed = await client.listTools(); + assert.deepEqual( + listed.tools.map((tool) => tool.name), + expectedToolNames, + ); + for (const tool of listed.tools) { + assert.equal(tool.inputSchema.type, 'object', `${tool.name} must advertise an object schema`); + } + + for (const call of workflow.calls) { + assert.equal(call.request.jsonrpc, '2.0'); + assert.equal(call.request.method, 'tools/call'); + const { name, arguments: args } = call.request.params; + const result = parseToolResult(await client.callTool({ name, arguments: args }), name); + assertExpected(result, call.expect, name); + } +} finally { + await client.close(); + await server.close(); +} + +process.stdout.write( + `${SERVER_NAME} MCP examples passed real initialization, ${expectedToolNames.length}-tool discovery, and the skill-guided settlement workflow.\n`, +); diff --git a/scripts/vendor/openai-skill-creator/UPSTREAM.md b/scripts/vendor/openai-skill-creator/UPSTREAM.md new file mode 100644 index 0000000..9f50713 --- /dev/null +++ b/scripts/vendor/openai-skill-creator/UPSTREAM.md @@ -0,0 +1,11 @@ +# OpenAI skill validator snapshot + +`quick_validate.py` is an unmodified snapshot of OpenAI's official +`skill-creator` validator: + +- Source: +- Retrieved: 2026-07-16 +- SHA-256: `6cc9dc3199c935916cf6f73fcbbbb0e3bb1b58c8f5109fefa499978908164f51` + +The repository check refuses to run if the snapshot differs from that pinned +digest. Upstream terms apply to this vendored file. diff --git a/scripts/vendor/openai-skill-creator/quick_validate.py b/scripts/vendor/openai-skill-creator/quick_validate.py new file mode 100644 index 0000000..0547b40 --- /dev/null +++ b/scripts/vendor/openai-skill-creator/quick_validate.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import re +import sys +from pathlib import Path + +import yaml + +MAX_SKILL_NAME_LENGTH = 64 + + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + content = skill_md.read_text() + if not content.startswith("---"): + return False, "No YAML frontmatter found" + + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} + + unexpected_keys = set(frontmatter.keys()) - allowed_properties + if unexpected_keys: + allowed = ", ".join(sorted(allowed_properties)) + unexpected = ", ".join(sorted(unexpected_keys)) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter.get("name", "") + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + if not re.match(r"^[a-z0-9-]+$", name): + return ( + False, + f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", + ) + if name.startswith("-") or name.endswith("-") or "--" in name: + return ( + False, + f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + False, + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters.", + ) + + description = frontmatter.get("description", "") + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + if "<" in description or ">" in description: + return False, "Description cannot contain angle brackets (< or >)" + if len(description) > 1024: + return ( + False, + f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/server.json b/server.json new file mode 100644 index 0000000..0543fbe --- /dev/null +++ b/server.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.Buzzr-app/dfs-engine", + "title": "Buzzr Sports Engine", + "description": "Local sports math, DFS settlement, bet analytics, and game-entertainment tools.", + "repository": { + "url": "https://github.com/Buzzr-app/dfs-engine", + "source": "github", + "id": "1234984143", + "subfolder": "packages/mcp" + }, + "version": "5.1.0", + "websiteUrl": "https://buzzr-app.github.io/dfs-engine/", + "packages": [ + { + "registryType": "npm", + "identifier": "@buzzr/mcp", + "version": "5.1.0", + "transport": { + "type": "stdio" + } + } + ] +} diff --git a/skills/buzzr-sports-engine/SKILL.md b/skills/buzzr-sports-engine/SKILL.md new file mode 100644 index 0000000..22e6b7c --- /dev/null +++ b/skills/buzzr-sports-engine/SKILL.md @@ -0,0 +1,91 @@ +--- +name: buzzr-sports-engine +description: Use Buzzr's local MCP server and TypeScript packages for deterministic sportsbook odds math, DFS pick-em validation and settlement, bet-history analytics, and game-entertainment scoring. Trigger for American-odds fair lines, parlays, EV or Kelly questions; PrizePicks- or Underdog-style entry grading; batch DFS audits; closing-line value; bet-history summaries; or game buzz and personalized ranking. Do not use it to fetch live lines, account data, official operator rulings, or financial advice. +--- + +# Buzzr Sports Engine + +Use Buzzr when the user needs auditable sports math instead of an estimated calculation. Prefer the MCP server for interactive analysis and the `@buzzr/*` packages for application code, custom policies, provider adapters, or high-volume pipelines. + +## Route the request + +1. Identify the domain: + - DFS settlement: `list_book_policies`, `validate_dfs_entry`, `grade_dfs_entry`, or `grade_dfs_entries`. + - Odds and bankroll math: `fair_line`, `closing_line_value`, `parlay_value`, or `kelly_stake`. + - Historical analysis: `summarize_bet_history`. + - Entertainment scoring: `predict_game_buzz` or `rank_games`. +2. Use the MCP tools when they are available in the current session. +3. If the tools are unavailable, help configure the local server or use the matching npm package in code. Do not imitate a tool result and present it as engine output. +4. Read [references/mcp-tools.md](references/mcp-tools.md) for the 11-tool catalog, limits, and response contracts. +5. Before any operator-specific DFS conclusion, follow [references/operator-safety.md](references/operator-safety.md). + +## Configure the local MCP server + +For repeatable work, pin the reviewed published version: + +```toml +[mcp_servers.buzzr] +command = "npx" +args = ["-y", "@buzzr/mcp@5.1.0"] +``` + +Use `@buzzr/mcp@latest` only when automatic upgrades are acceptable. The server uses stdio, writes protocol messages only to stdout, and needs no API key. Restart the client after changing its MCP configuration. + +If startup fails, check these in order: + +1. Node.js is version 22 or newer. +2. The package version exists: `npm view @buzzr/mcp@5.1.0 version`. +3. The executable starts cleanly: `npx -y @buzzr/mcp@5.1.0`. +4. The client launches the command directly rather than through an interactive shell. + +## Settle DFS entries + +Call `list_book_policies` before grading. Use only a policy/play type reported as `executable: true`. A draft fixture is documentation, not a grading implementation. + +Supply the slip's own `bookId`, `playTypeId`, `displayedMultiplier`, stake, placed timestamp, and leg details. Supply observed stats as `actual` or `actualsByLegId`; the MCP server does not fetch box scores. Mark DNP, void, or other operator rulings explicitly when known. + +Run `validate_dfs_entry` before grading untrusted or generated JSON. For one entry, call `grade_dfs_entry`. For 2–50 entries, call `grade_dfs_entries` and inspect both `results` and `failures`. A batch may contain at most 600 total legs. + +In the answer, surface: + +- entry status and payout; +- per-leg decisions and pending reasons; +- policy version, selected payout-table metadata, confidence, and source references; +- validation warnings and explanation codes; +- any gap between the slip's displayed terms and the compatibility profile. + +Never turn a `pending`, low-confidence, experimental, or unverified result into a definitive operator ruling. + +## Calculate odds and performance + +American odds inputs must be at most `-100` or at least `+100`, with magnitude no greater than `100000`. + +- Use `fair_line` only with both sides of the same two-way market. +- Use `closing_line_value` to compare the placed and closing prices for the same selection. +- Use `parlay_value` only when every leg includes selected and opposite prices. Explain that the calculation assumes independent legs unless the caller models correlation separately. +- Use `kelly_stake` only with a user-supplied win probability. Identify the fraction used and avoid framing the result as a recommendation to wager. +- Use `summarize_bet_history` for up to 500 normalized bet records. Its day/week/month buckets are UTC. + +## Score and rank games + +Use `predict_game_buzz` for one scheduled or final game. State which optional inputs were present because confidence changes with odds, injuries, team power, engagement, search heat, and star power. + +Use `rank_games` for 1–100 games and a bounded taste profile. Treat the output as an entertainment ranking, not a forecast of the game result. + +## Use the packages in code + +Reach for the package surface when the user is building software: + +- `@buzzr/dfs-engine` for policies, payout tables, providers, settlement, and audit trails; +- `@buzzr/bets-core` for odds and bet-history math; +- `@buzzr/entertainment-engine` for buzz predictions and recommendations; +- `@buzzr/dfs-engine-test-vectors` for engine regression fixtures; +- `@buzzr/dfs-testkit` for custom test fixtures. + +Keep operator rules as effective-dated data, preserve source metadata, validate inputs at boundaries, and pin related `@buzzr/*` package versions together. Published test vectors prove compatibility with the named engine version, not official operator conformance. + +## Report errors honestly + +Invalid tool arguments return a structured, bounded `invalid_input` result through MCP transports and direct handlers. Malformed JSON-RPC envelopes remain protocol errors. Other public errors are intentionally generic; do not infer internal details. + +If the tool is unavailable, input is missing, a policy is non-executable, or settlement remains pending, say exactly that and ask only for the missing evidence needed to proceed. diff --git a/skills/buzzr-sports-engine/agents/openai.yaml b/skills/buzzr-sports-engine/agents/openai.yaml new file mode 100644 index 0000000..5a1f595 --- /dev/null +++ b/skills/buzzr-sports-engine/agents/openai.yaml @@ -0,0 +1,8 @@ +interface: + display_name: "Buzzr Sports Engine" + short_description: "Auditable sports math and DFS settlement" + brand_color: "#FF6B35" + default_prompt: "Use $buzzr-sports-engine to settle a DFS entry or calculate sportsbook odds with explicit provenance." + +policy: + allow_implicit_invocation: true diff --git a/skills/buzzr-sports-engine/references/mcp-tools.md b/skills/buzzr-sports-engine/references/mcp-tools.md new file mode 100644 index 0000000..b3a8aa0 --- /dev/null +++ b/skills/buzzr-sports-engine/references/mcp-tools.md @@ -0,0 +1,38 @@ +# Buzzr MCP tool reference + +The local `@buzzr/mcp` server exposes 11 tools. It performs deterministic computation only; it does not fetch live odds, box scores, operator accounts, or private user data. + +| Tool | Use | Principal limits | +| --- | --- | --- | +| `grade_dfs_entry` | Settle one DFS pick-em entry | 1–12 unique legs | +| `grade_dfs_entries` | Settle a batch with isolated failures | 1–50 entries, 12 legs each, 600 total legs, concurrency 1–8 | +| `validate_dfs_entry` | Validate a candidate engine entry without settlement | 64 KiB JSON, 1,000 top-level fields | +| `list_book_policies` | Inspect executable compatibility profiles and non-executable drafts | No input | +| `fair_line` | Remove vig from a two-way market | Both American prices required | +| `closing_line_value` | Compare placed and closing implied probability | Same selection at both timestamps | +| `parlay_value` | Price independent parlay legs and optional EV | 1–50 legs | +| `kelly_stake` | Calculate full and fractional Kelly | Probability strictly between 0 and 1 | +| `summarize_bet_history` | Return rollup, UTC periods, drawdown, and streaks | Up to 500 unique bet IDs | +| `predict_game_buzz` | Score one game's entertainment value | Scheduled or final games | +| `rank_games` | Rank games for a taste profile | 1–100 games; optional top-N limit | + +American odds must be within `[-100000, -100]` or `[100, 100000]`. Tool strings, identifiers, lists, input frames, concurrent calls, and serialized output are bounded. + +## DFS result reading order + +1. `validation`: structural errors and compatibility-profile warnings. +2. `status`, `multiplier`, and `payout`: the engine outcome. +3. `legs`: observed value, decision, pending reason, and provider provenance. +4. `pendingReasons`: evidence still needed before settlement. +5. `policyVersion`, `payoutTable`, and `sourceRefs`: effective-dated rule metadata. +6. `confidence` and `explanationCodes`: limitations and machine-readable rationale. +7. `auditTrail`: ordered settlement decisions. + +`grade_dfs_entries`, `closing_line_value`, and `summarize_bet_history` use string `contractVersion: "1"`. Batch results preserve input order; inspect the separate failures array as well as summary counts. + +## Failure contracts + +- Invalid tool arguments return an `invalid_input` error result with bounded issue details through a real MCP client or a direct handler. +- Malformed JSON-RPC envelopes remain protocol errors owned by the MCP SDK. +- `server_busy`, `result_too_large`, `tool_execution_failed`, and `entry_settlement_failed` are intentionally generic public failures. +- An oversized or incomplete stdio frame is rejected without entering a tool handler. diff --git a/skills/buzzr-sports-engine/references/operator-safety.md b/skills/buzzr-sports-engine/references/operator-safety.md new file mode 100644 index 0000000..2a1b0c3 --- /dev/null +++ b/skills/buzzr-sports-engine/references/operator-safety.md @@ -0,0 +1,21 @@ +# Operator compatibility and safety + +Buzzr is independent open-source software. Built-in operator-named policies are versioned compatibility profiles, not official rules engines and not evidence of affiliation or endorsement. + +## Before grading + +1. Record the entry's placed timestamp and displayed payout or multiplier. +2. Call `list_book_policies` and confirm the exact policy/play type is executable. +3. Inspect its status, version, effective date, verification metadata, and source references. +4. Prefer the entry's displayed terms when they differ from a built-in table. +5. Obtain explicit operator rulings for DNPs, reboots, ties, rescues, voids, and stat corrections. Do not infer them from a box score alone. +6. Treat draft fixtures as metadata only. They must never be graded through the public MCP server. + +## Interpret confidence + +- `high` describes deterministic execution under the selected profile; it does not certify current official operator behavior. +- A verification cap, experimental profile, missing source, or warning lowers the conclusion that can be stated. +- `pending` means the engine lacks required evidence or cannot safely apply a policy. +- A payout-table source proves what data the engine used, not what an operator will pay. + +When money or a dispute is involved, tell the user to compare the result with the entry details and the operator's current terms. Present calculations as an audit aid, not legal or financial advice. diff --git a/tests/release-supply-chain.test.mjs b/tests/release-supply-chain.test.mjs new file mode 100644 index 0000000..abbe7e8 --- /dev/null +++ b/tests/release-supply-chain.test.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { afterEach, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const checkerPath = join(repositoryRoot, 'scripts', 'check-release-version.mjs'); +const temporaryRoots = []; + +const packageDefinitions = [ + { name: '@buzzr/bets-core', path: 'packages/bets-core', version: '5.0.0' }, + { + name: '@buzzr/dfs-cli', + path: 'packages/dfs-cli', + version: '5.0.1', + publish: true, + dependencies: { '@buzzr/dfs-engine': '^5.1.0' }, + }, + { + name: '@buzzr/dfs-engine', + path: 'packages/dfs-engine', + version: '5.1.0', + publish: true, + }, + { + name: '@buzzr/dfs-engine-test-vectors', + path: 'packages/dfs-engine-test-vectors', + version: '5.1.0', + publish: true, + dependencies: { '@buzzr/dfs-engine': '5.1.0' }, + }, + { + name: '@buzzr/dfs-provider-espn', + path: 'packages/dfs-provider-espn', + version: '5.0.0', + dependencies: { '@buzzr/dfs-engine': '^5.0.0' }, + }, + { + name: '@buzzr/dfs-provider-sportradar', + path: 'packages/dfs-provider-sportradar', + version: '5.0.0', + dependencies: { '@buzzr/dfs-engine': '^5.0.0' }, + }, + { + name: '@buzzr/dfs-react', + path: 'packages/dfs-react', + version: '5.0.0', + dependencies: { '@buzzr/dfs-engine': '^5.0.0' }, + }, + { + name: '@buzzr/dfs-testkit', + path: 'packages/dfs-testkit', + version: '5.0.1', + publish: true, + dependencies: { '@buzzr/dfs-engine': '^5.1.0' }, + }, + { + name: '@buzzr/entertainment-engine', + path: 'packages/entertainment-engine', + version: '5.0.0', + }, + { + name: '@buzzr/mcp', + path: 'packages/mcp', + version: '5.1.0', + publish: true, + dependencies: { + '@buzzr/bets-core': '5.0.0', + '@buzzr/dfs-engine': '5.1.0', + '@buzzr/entertainment-engine': '5.0.0', + }, + mcpName: 'io.github.Buzzr-app/dfs-engine', + }, +]; + +function releaseManifest(definitions = packageDefinitions) { + return { + releaseVersion: '5.1.0', + tag: 'v5.1.0', + registry: { + npm: 'https://registry.npmjs.org/', + mcpApi: 'https://registry.modelcontextprotocol.io/v0.1', + mcpServerName: 'io.github.Buzzr-app/dfs-engine', + mcpPackage: '@buzzr/mcp', + repository: 'https://github.com/Buzzr-app/dfs-engine', + workflowPath: '.github/workflows/release.yml', + ref: 'refs/heads/main', + }, + packages: definitions.map(({ name, path, version, publish = false, dependencies = {} }) => ({ + name, + path, + version, + publish, + internalDependencies: dependencies, + })), + }; +} + +async function writeJson(root, path, value) { + const destination = join(root, path); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, `${JSON.stringify(value, null, 2)}\n`); +} + +async function createFixture({ definitions = packageDefinitions, manifest, changeset } = {}) { + const root = await mkdtemp(join(tmpdir(), 'buzzr-release-check-')); + temporaryRoots.push(root); + await writeJson(root, 'package.json', { + name: 'buzzr-dfs-settlement-os', + version: '5.1.0', + private: true, + workspaces: ['packages/*'], + }); + for (const definition of definitions) { + await writeJson(root, `${definition.path}/package.json`, { + name: definition.name, + version: definition.version, + private: false, + publishConfig: { access: 'public' }, + dependencies: definition.dependencies ?? {}, + ...(definition.mcpName ? { mcpName: definition.mcpName } : {}), + }); + } + await writeJson(root, 'server.json', { + name: 'io.github.Buzzr-app/dfs-engine', + version: '5.1.0', + repository: { + url: 'https://github.com/Buzzr-app/dfs-engine', + source: 'github', + id: '1234984143', + subfolder: 'packages/mcp', + }, + packages: [ + { + registryType: 'npm', + identifier: '@buzzr/mcp', + version: '5.1.0', + transport: { type: 'stdio' }, + }, + ], + }); + await writeJson(root, 'release-manifest.json', manifest ?? releaseManifest()); + await writeJson(root, '.changeset/config.json', {}); + if (changeset) { + await writeFile(join(root, '.changeset', 'leftover.md'), changeset); + } + return root; +} + +function runChecker(root) { + return spawnSync(process.execPath, [checkerPath], { + cwd: root, + env: { ...process.env, EXPECTED_RELEASE_VERSION: '5.1.0' }, + encoding: 'utf8', + }); +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +test('accepts the exact reviewed ten-package and five-publish release manifest', async () => { + const result = runChecker(await createFixture()); + assert.equal(result.status, 0, result.stderr); +}); + +test('rejects version drift in every package, including packages omitted by the legacy checker', async () => { + const definitions = packageDefinitions.map((definition) => + definition.name === '@buzzr/dfs-cli' ? { ...definition, version: '9.9.9' } : definition, + ); + const result = runChecker(await createFixture({ definitions })); + assert.notEqual(result.status, 0, result.stdout); +}); + +test('rejects an unauthorized sixth publish', async () => { + const release = releaseManifest(); + release.packages = release.packages.map((definition) => + definition.name === '@buzzr/bets-core' ? { ...definition, publish: true } : definition, + ); + const result = runChecker(await createFixture({ manifest: release })); + assert.notEqual(result.status, 0, result.stdout); +}); + +test('rejects internal dependency range drift anywhere in the workspace', async () => { + const definitions = packageDefinitions.map((definition) => + definition.name === '@buzzr/dfs-provider-espn' + ? { ...definition, dependencies: { '@buzzr/dfs-engine': '*' } } + : definition, + ); + const result = runChecker(await createFixture({ definitions })); + assert.notEqual(result.status, 0, result.stdout); +}); + +test('rejects a remaining release changeset after versioning', async () => { + const result = runChecker( + await createFixture({ changeset: '---\n"@buzzr/mcp": patch\n---\n\nNot versioned.\n' }), + ); + assert.notEqual(result.status, 0, result.stdout); +}); diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..606ab81 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "packages/bets-core", + "packages/dfs-cli", + "packages/dfs-engine", + "packages/dfs-engine-test-vectors", + "packages/dfs-provider-espn", + "packages/dfs-provider-sportradar", + "packages/dfs-react", + "packages/dfs-testkit", + "packages/entertainment-engine", + "packages/mcp" + ], + "entryPointStrategy": "packages", + "packageOptions": { + "entryPoints": ["src/index.ts"], + "readme": "README.md", + "excludePrivate": true, + "excludeInternal": true + }, + "out": "docs/api", + "json": "docs/api/api.json", + "name": "Buzzr Sports Engine API", + "readme": "docs/api-reference.md", + "categorizeByGroup": true, + "highlightLanguages": [ + "bash", + "console", + "css", + "html", + "javascript", + "json", + "jsonc", + "json5", + "yaml", + "toml", + "tsx", + "typescript" + ], + "navigation": { + "includeCategories": true, + "includeGroups": true + }, + "githubPages": true, + "hideGenerator": true, + "treatWarningsAsErrors": true +}