Skip to content

feat(labeler): code/metadata AI adapter (W8.2)#1994

Open
ascorbic wants to merge 1 commit into
feat/plugin-registry-labelling-servicefrom
feat/labeler-code-ai-adapter
Open

feat(labeler): code/metadata AI adapter (W8.2)#1994
ascorbic wants to merge 1 commit into
feat/plugin-registry-labelling-servicefrom
feat/labeler-code-ai-adapter

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds the labeler's code/metadata AI adapter — plan item W8.2. analyzeCode(input, deps) analyzes a plugin's source files and metadata with a code model (Kimi @cf/moonshotai/kimi-k2.7-code) via the Workers AI binding (env.AI, injected — not AI Gateway) and returns validated NormalizedFinding[] (source "model"), feeding the W8.5 policy resolver.

  • Pure module. The AI binding is injected via deps; the module does no DB writes and imports nothing from cloudflare:workers, so it's fully testable against a fake binding and decoupled from the orchestrator's still-abstract StageContext. Wiring it into the codeAi stage — threading the acquired bundle and recording evidence objects — is deferred to the W7.3-consumer work once a real bundle-input producer exists.
  • Strict structured output. response_format: json_schema constrains each finding's category to exactly the policy's allowed set (automated-block ∪ warning) and severity/confidence to their ranges. The enum is derived from the versioned policy, not hardcoded.
  • Untrusted-input hardening. All plugin-controlled text — file content, file paths, and metadata — is wrapped in an untrusted fence with a unique per-request boundary token and sentinel-escaped, and the system prompt instructs the model to honor only an exact-token close. A malicious path or file can neither forge a boundary nor inject instructions.
  • Honest coverage. Input is budgeted against the assembled prompt size (escaping expansion + fences + paths + metadata + system prompt), not raw content. An oversized bundle drops largest-rendered files first and reports coverage: "partial" with the dropped paths — it never truncates silently.
  • Failure semantics. Model transport/parse failures, and model output that fails the W8.1 finding contract (e.g. an out-of-enum category), are ModelTransientError (retryable) — a flaky model is not the non-retryable stage-adapter bug a FindingValidationError signals. Model-supplied source/evidenceRefs/sourceMetadata are overridden so a model can't spoof provenance. A blank promptVersion is a caller bug and throws up front (not retried).

No migration, no lexicon change, no public-API change. Adds the AI binding to apps/labeler/wrangler.jsonc (regenerated worker-configuration.d.ts).

Reviewed with an opus adversarial pass that found and drove fixes for a fence-escape via unescaped paths, budget undercounting, a misclassified-retryable config bug, and two missing tests — all addressed here before opening.

Targets feat/plugin-registry-labelling-service. Related to #1909 and RFC #694.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: RFC RFC: Decentralized Plugin Registry #694

Admin i18n and changeset are n/a — this touches only apps/labeler, a private, unpublished Worker app (no admin UI, not on npm).

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Code with Claude Opus 4.8 (design, adversarial review, fixes) and a Sonnet subagent (implementation)

Screenshots / test output

  • pnpm --filter @emdash-cms/labeler test: 228 pass (16 files) — 17 new for the adapter (happy path; content-fence + malicious-path injection resistance; model-field override; schema enum = policy; transport/parse/out-of-enum → ModelTransientError; blank promptVersion → TypeError; oversized-bundle coverage/drops; no-cache option; promptHash stability + policy-sensitivity)
  • Typecheck clean; pnpm lint:json diagnostics unchanged at 19 (0 in apps/labeler)
  • Tests use a fake AiBinding only — never the live model (Workers AI bills even in local dev)

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks feat/labeler-code-ai-adapter. Updated automatically when the playground redeploys.

Add analyzeCode: a pure module that analyzes a plugin's source and
metadata with Kimi via the injected env.AI binding and returns validated
NormalizedFinding[] (source "model"), with strict JSON-schema output
constrained to the policy's allowed categories.

Plugin-controlled text (file content, paths, metadata) is fenced as
untrusted with a per-request boundary and sentinel-escaped so it can't
forge a boundary or inject instructions. Model/parse failures and
model-produced invalid findings are ModelTransientError (retryable);
model-supplied source/evidenceRefs/sourceMetadata are overridden. Input
is budgeted against assembled prompt size and degrades coverage to
partial rather than truncating silently.

Not yet wired to the orchestrator's codeAi stage; that waits on the
W7.3 bundle-input producer.
@changeset-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ed11953

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 652 lines across 4 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added size/XL review/needs-review No maintainer or bot review yet cla: signed labels Jul 12, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do ed11953 Jul 12 2026, 10:09 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache ed11953 Jul 12 2026, 10:10 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground ed11953 Jul 12 2026, 10:11 PM

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a self-contained W8.2 code/metadata AI adapter for the labeler, and the approach is sound: a pure module with injected AI binding, deterministic output normalization through validateFindings, explicit provenance overrides, fence escaping, and retry/failure semantics mapped to ModelTransientError.

I checked the prompt-fence escaping, path injection hardening, field-override behavior, policy-derived schema enum, failure mapping, Worker config updates, and test coverage. They all look correct and well-tested.

The one real issue is in capFilesToBudget: the per-file cost and overhead constants undercount the actual assembled user content by N + 1 newline characters for N files. That contradicts the PR's "honest coverage" guarantee—near the 200 k-char limit the adapter can still report coverage: "complete" while sending a slightly oversized context to the model. This is small but exactly the kind of silent truncation the change is meant to prevent.

Fixing the arithmetic and adding a close-to-limit regression test would make this clean to approve.

Comment on lines +264 to +265
const renderedCost = (file: CodeAnalysisFile) => buildFence(file, boundary).length + 1;
const total = files.reduce((sum, file) => sum + renderedCost(file), fixedOverheadChars);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] buildUserContent assembles the user message by joining [metadataFence, "", file1Fence, "", file2Fence, "", ...], which produces 2N + 1 newline characters for N files. However, the budget math in capFilesToBudget only adds + 1 newline per file and starts from fixedOverheadChars without accounting for the leading separator between metadataFence and the first empty part. The assembled prompt is therefore undercounted by N + 1 characters.

For a bundle near the 200 k-char limit this allows the adapter to mark coverage: "complete" while still exceeding the model's context window, silently truncating instead of reporting partial coverage.

Suggested change
const renderedCost = (file: CodeAnalysisFile) => buildFence(file, boundary).length + 1;
const total = files.reduce((sum, file) => sum + renderedCost(file), fixedOverheadChars);
const renderedCost = (file: CodeAnalysisFile) => buildFence(file, boundary).length + 2;
const total = files.reduce((sum, file) => sum + renderedCost(file), fixedOverheadChars + 1);

The current oversized-bundle test uses very large files, so please also add a regression test sized just under/over the real 200 k limit so this arithmetic doesn't drift again.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed review/awaiting-author Reviewed; waiting on the author to respond size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant