pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! Phase 3 task 1: deterministic symbol-level mapper (spec §7.1).
//! Committed first, read-only hereafter (charter §4.1). The paired API
//! stubs in `src/mapper.rs` land in the same commit so this crate
//! compiles; the assertions below are red until the implementation
//! commit. Reference resolution happens ONCE at the boundary; ambiguity
//! and misses are structured errors with candidates, never silent.

use pushkin_core::manifest::Manifest;
use pushkin_core::mapper::{resolve_reference, slices_for_write, ReferenceError};

const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
protected_paths = ["pushkin.toml"]
"#;

/// Two contracts sharing a prefix, for ambiguity cases.
const MANIFEST_TWO: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user-api"
source = "contracts/user-api.zod.ts"
emit = ["zod"]

[[contracts]]
name = "user-admin"
source = "contracts/user-admin.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user-api"]

[gates]
protected_paths = []
"#;

const CONTRACT_SOURCE: &str = "import { z } from \"zod\";\n\n\
export const UserCreateSchema = z.object({ name: z.string(), email: z.string() }).strict();\n\
export const UserAdminSchema = z.object({ role: z.string() }).strict();\n";

const HANDLER_TOUCHING_CREATE_ONLY: &str =
    "import { UserCreateSchema } from \"../../../contracts/user.zod\";\n\n\
export async function POST(req: Request) {\n\
  const body = UserCreateSchema.parse(await req.json());\n\
  return Response.json(body);\n\
}\n";

// ---------- glob -> contract slices ----------

#[test]
fn glob_maps_touched_file_to_contract_slice() {
    let manifest = Manifest::parse(MANIFEST).unwrap();
    let sources = [("user", CONTRACT_SOURCE)];
    let slices = slices_for_write(
        &manifest,
        "app/api/users/route.ts",
        HANDLER_TOUCHING_CREATE_ONLY,
        &sources,
    );
    assert_eq!(slices.len(), 1, "one mapped contract, one slice");
    assert_eq!(slices[0].contract.as_str(), "user");

    let unmapped = slices_for_write(&manifest, "docs/notes.ts", "const x = 1;", &sources);
    assert!(unmapped.is_empty(), "unmapped paths produce no slices");
}

#[test]
fn symbol_slice_covers_only_touched_symbols() {
    let manifest = Manifest::parse(MANIFEST).unwrap();
    let sources = [("user", CONTRACT_SOURCE)];
    let slices = slices_for_write(
        &manifest,
        "app/api/users/route.ts",
        HANDLER_TOUCHING_CREATE_ONLY,
        &sources,
    );
    assert_eq!(
        slices[0].symbols,
        vec!["UserCreateSchema".to_owned()],
        "handler touches UserCreateSchema only — UserAdminSchema must not be in the slice"
    );
}

// ---------- boundary reference resolution ----------

#[test]
fn shorthand_resolves_once_at_boundary_to_canonical_ref() {
    let manifest = Manifest::parse(MANIFEST).unwrap();
    // Exact canonical name.
    assert_eq!(
        resolve_reference(&manifest, "user").unwrap().as_str(),
        "user"
    );
    // Source-path shorthand resolves to the same canonical name.
    assert_eq!(
        resolve_reference(&manifest, "contracts/user.zod.ts")
            .unwrap()
            .as_str(),
        "user"
    );
}

#[test]
fn ambiguous_contract_reference_returns_candidates() {
    let manifest = Manifest::parse(MANIFEST_TWO).unwrap();
    let error = resolve_reference(&manifest, "user").unwrap_err();
    let ReferenceError::Ambiguous { candidates, .. } = &error else {
        panic!("expected Ambiguous, got: {error}");
    };
    assert!(candidates.contains("user-api"), "names both: {candidates}");
    assert!(
        candidates.contains("user-admin"),
        "names both: {candidates}"
    );
}

#[test]
fn unknown_reference_is_structured_error_not_silent_miss() {
    let manifest = Manifest::parse(MANIFEST).unwrap();
    let error = resolve_reference(&manifest, "uzer").unwrap_err();
    let ReferenceError::Unknown { candidates, .. } = &error else {
        panic!("expected Unknown, got: {error}");
    };
    assert!(
        candidates.contains("user"),
        "near-miss candidate named: {candidates}"
    );
}