/** * Selected-profile closure classifier (W5 a2). The fast-scan gate scans the WHOLE * hashed source tree; this module computes, orthogonally, the transitive set of * files a SELECTED install/runtime profile actually executes, sources, imports, * builds, or model-loads — so a finding in a file the profile never runs can be * reported without gating (the "materialized but proven inert" bucket), while a * finding in the executed/loaded closure (or one whose reachability we cannot * prove) still blocks. * * The classifier is PURE and host-fact-injected: it takes a flat file list + a * text reader (so the gate can back it with a scan inventory and tests can back * it with an in-memory map) and a {@link ClosureSpec} naming the profile's entry * points. It never reads the disk itself and never executes upstream code. * * Fail-closed by construction: an unresolved reference (dynamic path, non-literal * import) becomes an `unknown` node (blocking); an ABSENT host fact makes every * still-inert file `unknown` (blocking), because we cannot then prove the host * ignores the repo-shaped skills copy. */ /** * How a profile reaches a file. `control` (executed/sourced/hooked), * `build-input` (compiled/bundled into a runtime artifact), and `model-loaded` * (a skill body/section the host reads into the model) are the three BLOCKING * "in-closure" kinds. `unknown` is blocking too — a reference we could not prove * inert. `materialized` is on disk but reached by none of the above → inert. */ export type Reachability = "control" | "build-input" | "model-loaded" | "materialized" | "unknown"; /** The three blocking kinds a seed or a resolved edge can carry. */ export type BlockingReachability = "control" | "build-input" | "model-loaded"; /** * The classification the gate acts on. `closure` = blocking (control/build-input/ * model-loaded/unknown roll up here); `materialized-inert` = reported, non-blocking; * `non-materialized` = the profile does not even place the file on disk (report-only). */ export type FindingClassification = "closure" | "materialized-inert" | "non-materialized"; /** * Measured facts about the HOST's skill loader that the tree alone cannot reveal. * * These bind to a specific host: `hostVersion` is folded into the closure's * `hostFactsDigest`, so a disposition classified under one host tuple is NOT * valid for another — re-probe on a host change. * * Adjacency semantics (orchestrator-pinned): `readsNonSkillSkillFiles` refers * ONLY to files adjacent to REGISTERED wrapper skills (their `sections/` and the * paths a SKILL.md body references) — which the profile's seeds already classify * `model-loaded` via reference extraction regardless of this flag. It NEVER * refers to the repo-shaped skills copy as a whole. Consequently, when * `registersNestedSkillMd` is false, a false/unmeasured `readsNonSkillSkillFiles` * does NOT expand nested repo-copy files to `unknown`: the model-read channel * into the copy is covered by extraction from model-loaded surfaces instead. * Only an ENTIRELY absent host fact (undefined) makes nested files `unknown`. */ export interface HostLoadFacts { /** The host tuple these facts were probed on, e.g. "claude-code@2.1.214". */ hostVersion: string; /** Does the host register a SKILL.md nested inside a skills subtree (the repo-shaped copy)? */ registersNestedSkillMd: boolean; /** Does the host read non-SKILL files ADJACENT to a registered wrapper skill (see doc note)? */ readsNonSkillSkillFiles: boolean; /** Provenance: the probe evidence establishing the fact (audit only). */ probeEvidence: string; } /** One entry-point seed: a present file the profile reaches directly. */ export interface ClosureSeed { path: string; reachability: BlockingReachability; } /** * The profile's closure model. `mode: "full-tree"` classifies EVERY materialized * file as `control` (the W4 back-compat model: full-tree closure ⇒ the gate * reduces to "any ≥high blocks", exactly the pre-a2 behavior). `mode: "seeded"` * runs the transitive fixpoint from `seeds`. */ export interface ClosureSpec { /** Stable profile id, e.g. "claude:prefix:quiet:no-plan-tune-hooks". Part of closure identity. */ profile: string; /** Extraction ruleset version; bump on any rule change (participates in the closure digest). */ classifierVersion: number; mode: "full-tree" | "seeded"; /** Entry points for `mode: "seeded"`; ignored for `mode: "full-tree"`. */ seeds?: readonly ClosureSeed[]; /** * Whether the profile materializes a given file (default: all present files). * A present file the profile does NOT materialize classifies `non-materialized` * (report-only) — nearly empty for whole-checkout-copy profiles. */ materializes?: (path: string) => boolean; } /** The file universe + a text reader; injected so the classifier stays pure. */ export interface ClosureInput { /** All present file paths (source-relative POSIX), typically the scan inventory. */ files: readonly string[]; /** Read a file's UTF-8 text, or undefined if unreadable. */ readText: (path: string) => string | undefined; } /** The current classifier ruleset version. Bump on any extraction-rule change. */ export declare const CLOSURE_CLASSIFIER_VERSION = 1; /** The reserved profile id for the W4 full-tree closure. */ export declare const FULL_TREE_PROFILE = "full-tree"; /** The W4 back-compat spec: every materialized file is `control` (no fixpoint). */ export declare function fullTreeClosureSpec(): ClosureSpec; /** One classified file. `reachedBy` records the seed/edge provenance (audit). */ export interface ClosureNode { path: string; reachability: Reachability; materialized: boolean; reachedBy: readonly string[]; } export interface ProfileClosure { spec: ClosureSpec; /** Exactly one node per present file. */ nodes: ReadonlyMap; /** Bare/out-of-tree references (e.g. node_modules specifiers) — recorded, non-blocking. */ danglingRefs: readonly string[]; /** Fully-dynamic references that widened nothing — disclosed, non-gating on their own. */ unresolvedRefs: readonly string[]; /** sha256 of (profile + classifierVersion + mode + hostVersion + sorted node classification). */ closureDigest: string; /** sha256 of the host facts, or "absent". Binds a disposition to its host tuple. */ hostFactsDigest: string; } /** * Classify every file in `input` against `spec` (and the injected `hostFacts`). * Deterministic: identical (input, spec, hostFacts) yields an identical * `closureDigest`. `hostFacts === undefined` fails closed (every inert file → * `unknown`). */ export declare function classifyClosure(input: ClosureInput, spec: ClosureSpec, hostFacts?: HostLoadFacts): ProfileClosure; /** * Classify a finding's file against the closure. A file absent from the closure * (never in the inventory) fails closed to blocking `unknown` — a finding can * never be silently declared inert for a file the closure did not see. */ export declare function classificationOf(closure: ProfileClosure, path: string): { classification: FindingClassification; reachability: Reachability | "non-materialized"; };