Skip to content

SDK: lintExtensionBlueprint and assertExtensionPreflight pass a blueprint the loader hard-rejects, so the host-owned flag collision that quarantines an extension has no static check #784

Closed

Description

@unbraind

Summary

lintExtensionBlueprint() and assertExtensionPreflight() both return ok: true with zero findings for a blueprint declaring --json, which createExtensionTestHarness() / the loader then reject outright. The two APIs whose entire purpose is to vet a blueprint before activation are blind to the one defect class that disables a whole extension.

The data needed for the check is already public: @unbrained/pm-cli/sdk/contracts exports SUBCOMMAND_GLOBAL_FLAG_CONTRACTS, and the loader already produces an excellent message for the same condition.

Reproduction (2026.7.28, clean install)

import { lintExtensionBlueprint, assertExtensionPreflight } from "@unbrained/pm-cli/sdk/testing";

const blueprint = {
  name: "probe-ext", version: "1.0.0",
  activate(api) {
    api.registerCommand({
      command: "probe run", action: "run", description: "probe",
      flags: [{ long: "--json", description: "collides with host global", value_type: "boolean" }],
      handler: () => ({ ok: true }),
    });
  },
};

await lintExtensionBlueprint(blueprint);
// => { ok: true, findings: [] }

await assertExtensionPreflight(blueprint, { identity: { name: "probe-ext", version: "1.0.0" } });
// => { ok: true, findings: [] }

Activating the very same blueprint fails, correctly and with a great message:

createExtensionTestHarness could not activate the extension cleanly: project:probe-ext
(registerFlags flags[0] cannot shadow host-owned global flag "--json"; read it from
context.global instead)

Why this matters

This is the GH-719 / GH-764 defect class. It previously broke 9 of 18 extensions in our fleet at once — 22 shadowing flag declarations, each one quarantining its entire extension, all shipped green. 2026.7.28 making the loader hard-fail is a big improvement and we rely on it. But it only fires for authors whose tests actually activate the extension. An author who reaches for the purpose-built static checker — which is what lintExtensionBlueprint/assertExtensionPreflight advertise themselves as — gets an explicit all-clear on a blueprint that cannot run.

ExtensionBlueprintLintCode today is:

"capability_undeclared" | "capability_unused" | "duplicate_command" |
"command_override_conflict" | "empty_surface" | "reserved_item_field" |
"manifest_capabilities_absent"

Note reserved_item_field already exists — reserved item fields are linted, reserved flags are not, though the flag case has strictly worse consequences (a reserved item field is one field; a reserved flag is the whole extension).

Proposed fix

Add a host_owned_flag_collision code to ExtensionBlueprintLintCode at error severity, raised by lintExtensionBlueprint (and therefore surfaced through assertExtensionPreflight) whenever a declared flag's long value matches a SUBCOMMAND_GLOBAL_FLAG_CONTRACTS entry's flag or any of its aliases. Reusing the loader's existing wording — including the "read it from context.global instead" remediation — would make the static and runtime messages identical.

Secondary: key asymmetry makes the manual cross-check awkward

Writing this check by hand requires knowing that the same concept is spelled differently on each side:

  • extension-declared flags carry the token in FlagDefinition.long
  • the host contract carries it in SUBCOMMAND_GLOBAL_FLAG_CONTRACTS[].flag (+ .aliases)

So the join is flag.long === contract.flag, which reads like a bug at every call site. Not worth a breaking rename on its own, but if the lint rule above lands, no consumer needs to write the join at all.

Environment

  • @unbrained/pm-cli 2026.7.28 (fresh npm install, no local patches)
  • Node 24, Linux

Activity

  1. coderabbitai commented on Jul 28, 2026

    @coderabbitai
    ⚠️ Possible Duplicate Issue(s)
    🔗 Related PRs

    #347 - feat(sdk): author-time blueprint introspection & preflight (describeExtensionBlueprint + lintExtensionBlueprint + assertExtensionBlueprint) [merged]
    #351 - feat(sdk): unified author-time preflight capstone — preflightExtension + assertExtensionPreflight [merged]
    #479 - feat(package): isolate project doctor diagnostics [merged]
    #561 - SDK: unify extension input and output host contracts [merged]
    #657 - SDK extension authoring, trust boundaries, and scoped rendering [merged]
    #734 - SDK host contracts, live agent context, and portable extension delivery [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false
    

    💬 Have feedback or questions? Drop into our discord!

  2. unbraind commented on Jul 28, 2026

    @unbraind
    OwnerAuthor

    Correcting my own reproduction above, and adding a second finding in the same validator.

    Corrected reproduction

    The snippet I first posted was itself invalid — the command definition was missing name, and registerCommand with flags requires the schema capability. It therefore failed activation for unrelated reasons. The conclusion is unchanged and still reproduces, but here is a repro that actually runs:

    import { createExtensionTestHarness, lintExtensionBlueprint } from "@unbrained/pm-cli/sdk/testing";
    
    const mk = (long) => ({
      name: "probe-ext", version: "1.0.0",
      manifest: { name: "probe-ext", version: "1.0.0", capabilities: ["commands", "schema"] },
      activate(api) {
        api.registerCommand({
          name: "probe run", command: "probe run", action: "run", description: "probe",
          flags: [{ long, description: "probe", value_type: "boolean" }],
          handler: () => ({ ok: true }),
        });
      },
    });
    
    for (const f of ["--fine", "--json", "verbose", "-v"]) {
      const lint = await lintExtensionBlueprint(mk(f));
      let act;
      try { await createExtensionTestHarness(mk(f), { name: "probe-ext", capabilities: ["commands", "schema"] }); act = "ACCEPTED"; }
      catch (e) { act = "REJECTED"; }
      console.log(f, "lint.ok=" + lint.ok, "activation=" + act);
    }

    Output on 2026.7.28:

    --fine    lint.ok=true  activation=ACCEPTED
    --json    lint.ok=true  activation=REJECTED   <-- the reported gap
    verbose   lint.ok=true  activation=ACCEPTED   <-- second finding, below
    -v        lint.ok=true  activation=ACCEPTED   <-- second finding, below
    

    Row 2 is the original report: lint says the blueprint is clean, the loader rejects it with registerFlags flags[0] cannot shadow host-owned global flag "--json".

    Second finding: flag token form is never validated

    Rows 3 and 4 are a separate gap that surfaced while building the corrected repro. A flag declared as verbose (no leading dashes) or -v (single dash, i.e. a short flag in the long field) is accepted by both the linter and the loader, and the token is stored verbatim:

    --verbose  stored=["--verbose"]
    verbose    stored=["verbose"]
    -v         stored=["-v"]
    

    No normalization, no warning, no error.

    Scoping this claim honestly: I verified that the token is stored as-written and that neither validator objects. I did not verify end-to-end that the real CLI parser fails to bind verbose/-v — that needs a fully installed extension against real argv, which I have not run. So I am reporting this as "the declared form is never validated", not as "the flag is definitely unreachable". If the parser does normalize these downstream then this is only a lint nit; if it does not, the flag is silently dead in exactly the way GH-719 flags were.

    Either way it seems worth deciding deliberately, since both cases are cheap to catch in the same place as the collision rule — a long that does not match /^--[a-z0-9][a-z0-9-]*$/ is almost certainly an authoring typo.

  3. unbraind commented on Jul 28, 2026

    @unbraind
    OwnerAuthor

    Tracked canonically in pm-huolbk. The item links the closed author-time lint and GH-719/GH-764 runtime-collision lineage, includes both the host-owned collision and malformed long token findings, and remains open/unclaimed.

  4. unbraind commented on Jul 28, 2026

    @unbraind
    OwnerAuthor

    Implemented in #798 and tracked in pm-huolbk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions