Skip to content

Commit aa6ea2f

Browse files
digitaraldyxbhCopilot
authored
feat(policy): support native PolicyPlugin exports from module files (#141)
Allow policy modules to export a native PolicyPlugin object (with detectors, hooks, and recommenders) instead of being limited to the PolicyConfig criteria DSL. - Add isNativePlugin() type guard and validateNativePlugin() validator - loadPolicy() returns PolicyConfig | PolicyPlugin union - loadPluginChain() detects and adds native plugins directly - Auto-enable engine path when native plugins present in readiness - pathToFileURL fix for Windows dynamic imports - Harden normalizeNativePlugin to unconditionally force sourceType/trust Supersedes #115. Co-authored-by: yxbh <3335392+yxbh@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1b9e635 commit aa6ea2f

8 files changed

Lines changed: 711 additions & 19 deletions

File tree

packages/core/src/services/policy.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import fs from "fs/promises";
22
import path from "path";
3+
import { pathToFileURL } from "url";
34

45
import { readJson, stripJsonComments } from "../utils/fs";
56

7+
import type { PolicyPlugin } from "./policy/types";
8+
import { isNativePlugin, validateNativePlugin } from "./policy/types";
69
import type { ReadinessCriterion, ReadinessContext } from "./readiness";
710

811
// ─── Policy configuration types ───
@@ -158,10 +161,39 @@ export function parsePolicySources(raw: string | undefined): string[] | undefine
158161

159162
// ─── Loading ───
160163

164+
/**
165+
* Normalize a native plugin export to ensure `meta.sourceType` and `meta.trust`
166+
* are set correctly. Module-loaded plugins are always `sourceType: "module"` and
167+
* `trust: "trusted-code"` regardless of what the export declares — a module
168+
* cannot claim to be "builtin" or "safe-declarative".
169+
*/
170+
function normalizeNativePlugin(plugin: PolicyPlugin): PolicyPlugin {
171+
return {
172+
...plugin,
173+
meta: {
174+
...plugin.meta,
175+
sourceType: "module",
176+
trust: "trusted-code"
177+
}
178+
};
179+
}
180+
181+
/**
182+
* Load a policy from a file path or npm specifier.
183+
*
184+
* Returns either a PolicyConfig (for traditional criteria-based policies)
185+
* or a PolicyPlugin (for native plugins that export the full plugin contract).
186+
* Native plugins are detected via `isNativePlugin()`: they must have a `meta`
187+
* object with a non-empty `meta.name` string, and must NOT have a root-level
188+
* `name` string (which would indicate a PolicyConfig).
189+
*
190+
* Native plugin exports are normalised with `sourceType: "module"` and
191+
* `trust: "trusted-code"` regardless of what the export declares.
192+
*/
161193
export async function loadPolicy(
162194
source: string,
163195
options?: { jsonOnly?: boolean }
164-
): Promise<PolicyConfig> {
196+
): Promise<PolicyConfig | PolicyPlugin> {
165197
const jsonOnly = options?.jsonOnly ?? false;
166198

167199
// Local file path (relative or absolute)
@@ -182,8 +214,17 @@ export async function loadPolicy(
182214
);
183215
}
184216
try {
185-
const mod = (await import(resolved)) as Record<string, unknown>;
217+
// Use pathToFileURL to convert filesystem paths to file:// URLs.
218+
// On Windows, path.resolve() returns paths like C:\... which dynamic
219+
// import() treats as a URL scheme (c:), causing ERR_UNSUPPORTED_ESM_URL_SCHEME.
220+
const mod = (await import(pathToFileURL(resolved).href)) as Record<string, unknown>;
186221
const config = (mod.default ?? mod) as unknown;
222+
// Native PolicyPlugin exports have a `meta` property instead of a root-level `name`.
223+
// Detect and return them directly without PolicyConfig validation.
224+
if (isNativePlugin(config)) {
225+
validateNativePlugin(config, source);
226+
return normalizeNativePlugin(config);
227+
}
187228
return validatePolicyConfig(config, source);
188229
} catch (err) {
189230
if (
@@ -216,6 +257,11 @@ export async function loadPolicy(
216257
try {
217258
const mod = (await import(source)) as Record<string, unknown>;
218259
const config = (mod.default ?? mod) as unknown;
260+
// Native PolicyPlugin exports from npm packages
261+
if (isNativePlugin(config)) {
262+
validateNativePlugin(config, source);
263+
return normalizeNativePlugin(config);
264+
}
219265
return validatePolicyConfig(config, source);
220266
} catch (err) {
221267
const message =

packages/core/src/services/policy/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type {
1919
EngineReport,
2020
Grade
2121
} from "./types";
22-
export { calculateScore } from "./types";
22+
export { calculateScore, isNativePlugin, validateNativePlugin } from "./types";
2323
export { executePlugins } from "./engine";
2424
export type { EngineOptions } from "./engine";
2525
export { compilePolicyConfig } from "./compiler";

packages/core/src/services/policy/loader.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { compilePolicyConfig } from "./compiler";
2020
import type { CompilationResult } from "./compiler";
2121
import type { EngineOptions } from "./engine";
2222
import type { PolicyPlugin } from "./types";
23+
import { isNativePlugin } from "./types";
2324

2425
export type LoadedChain = {
2526
plugins: PolicyPlugin[];
@@ -80,10 +81,27 @@ export async function loadPluginChain(
8081
let passRateThreshold = 0.8;
8182

8283
for (const source of policySources) {
83-
const policyConfig: PolicyConfig = await loadPolicy(source, {
84+
const loaded = await loadPolicy(source, {
8485
jsonOnly: options?.jsonOnly
8586
});
8687

88+
// Native PolicyPlugin exports — use directly with trusted-code trust.
89+
// These modules export the full plugin contract (detectors, hooks, recommenders)
90+
// instead of the PolicyConfig DSL (criteria.add/disable/override).
91+
if (isNativePlugin(loaded)) {
92+
plugins.push({
93+
...loaded,
94+
meta: {
95+
...loaded.meta,
96+
sourceType: "module",
97+
trust: "trusted-code"
98+
}
99+
});
100+
continue;
101+
}
102+
103+
const policyConfig: PolicyConfig = loaded;
104+
87105
// Check if this is a module policy (imperative plugin) with code-level hooks
88106
if (isImperativePlugin(policyConfig)) {
89107
// Module policies: wrap as trusted-code plugin

packages/core/src/services/policy/types.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,127 @@ export type PolicyPlugin = {
157157
onError?: (error: Error, stage: PluginStage, ctx: PolicyContext) => boolean;
158158
};
159159

160+
// ─── Type guards ───
161+
162+
/**
163+
* Detect whether a loaded module export is a native PolicyPlugin.
164+
*
165+
* Detection rules:
166+
* 1. Must have a `meta` object with a non-empty `meta.name` string
167+
* 2. Must NOT have a root-level `name` string (which would indicate a PolicyConfig)
168+
* 3. If `meta.sourceType` or `meta.trust` are provided, they must be valid values
169+
*
170+
* Note: This is a detection heuristic, not a full validation. The loader normalises
171+
* `meta.sourceType` and `meta.trust` after detection (overriding with "module" and
172+
* "trusted-code"), so these fields are optional in the module export.
173+
* Use `validateNativePlugin()` after detection to verify the plugin has valid hooks.
174+
*/
175+
export function isNativePlugin(obj: unknown): obj is PolicyPlugin {
176+
if (typeof obj !== "object" || obj === null) return false;
177+
const record = obj as Record<string, unknown>;
178+
if (typeof record.meta !== "object" || record.meta === null) return false;
179+
if (typeof record.name === "string") return false;
180+
const meta = record.meta as Record<string, unknown>;
181+
if (typeof meta.name !== "string" || meta.name.trim().length === 0) return false;
182+
// Reject if meta fields are present but invalid
183+
if (
184+
meta.sourceType !== undefined &&
185+
!["module", "json", "builtin"].includes(meta.sourceType as string)
186+
)
187+
return false;
188+
if (
189+
meta.trust !== undefined &&
190+
!["trusted-code", "safe-declarative"].includes(meta.trust as string)
191+
)
192+
return false;
193+
return true;
194+
}
195+
196+
/**
197+
* Validate that a native plugin export has the minimum required structure.
198+
* Checks that hooks are the correct types and that detector/recommender arrays
199+
* contain objects with the expected callable members.
200+
* Throws descriptive errors for invalid plugins so issues are caught at load time.
201+
*/
202+
export function validateNativePlugin(obj: PolicyPlugin, source: string): void {
203+
const { meta } = obj;
204+
if (!meta.name?.trim()) {
205+
throw new Error(`Native plugin "${source}" is invalid: meta.name is required`);
206+
}
207+
208+
// Validate hook functions
209+
if (obj.afterDetect !== undefined && typeof obj.afterDetect !== "function") {
210+
throw new Error(`Native plugin "${source}" is invalid: afterDetect must be a function`);
211+
}
212+
if (obj.beforeRecommend !== undefined && typeof obj.beforeRecommend !== "function") {
213+
throw new Error(`Native plugin "${source}" is invalid: beforeRecommend must be a function`);
214+
}
215+
if (obj.afterRecommend !== undefined && typeof obj.afterRecommend !== "function") {
216+
throw new Error(`Native plugin "${source}" is invalid: afterRecommend must be a function`);
217+
}
218+
if (obj.onError !== undefined && typeof obj.onError !== "function") {
219+
throw new Error(`Native plugin "${source}" is invalid: onError must be a function`);
220+
}
221+
222+
// Validate detector array members
223+
if (obj.detectors !== undefined) {
224+
if (!Array.isArray(obj.detectors)) {
225+
throw new Error(`Native plugin "${source}" is invalid: detectors must be an array`);
226+
}
227+
for (const [i, d] of obj.detectors.entries()) {
228+
if (typeof d !== "object" || d === null) {
229+
throw new Error(`Native plugin "${source}" is invalid: detectors[${i}] must be an object`);
230+
}
231+
if (typeof d.id !== "string" || !d.id.trim()) {
232+
throw new Error(
233+
`Native plugin "${source}" is invalid: detectors[${i}].id must be a non-empty string`
234+
);
235+
}
236+
if (typeof d.detect !== "function") {
237+
throw new Error(
238+
`Native plugin "${source}" is invalid: detectors[${i}].detect must be a function`
239+
);
240+
}
241+
}
242+
}
243+
244+
// Validate recommender array members
245+
if (obj.recommenders !== undefined) {
246+
if (!Array.isArray(obj.recommenders)) {
247+
throw new Error(`Native plugin "${source}" is invalid: recommenders must be an array`);
248+
}
249+
for (const [i, r] of obj.recommenders.entries()) {
250+
if (typeof r !== "object" || r === null) {
251+
throw new Error(
252+
`Native plugin "${source}" is invalid: recommenders[${i}] must be an object`
253+
);
254+
}
255+
if (typeof r.id !== "string" || !r.id.trim()) {
256+
throw new Error(
257+
`Native plugin "${source}" is invalid: recommenders[${i}].id must be a non-empty string`
258+
);
259+
}
260+
if (typeof r.recommend !== "function") {
261+
throw new Error(
262+
`Native plugin "${source}" is invalid: recommenders[${i}].recommend must be a function`
263+
);
264+
}
265+
}
266+
}
267+
268+
const hasHooks =
269+
obj.detectors?.length ||
270+
obj.afterDetect ||
271+
obj.beforeRecommend ||
272+
obj.recommenders?.length ||
273+
obj.afterRecommend;
274+
if (!hasHooks) {
275+
throw new Error(
276+
`Native plugin "${source}" is invalid: must implement at least one hook (detectors, afterDetect, beforeRecommend, recommenders, or afterRecommend)`
277+
);
278+
}
279+
}
280+
160281
// ─── Engine output ───
161282

162283
/** Grade label for a readiness score. */

packages/core/src/services/readiness/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { loadPolicy, resolveChain } from "../policy";
77
import { executePlugins } from "../policy/engine";
88
import { loadPluginChain } from "../policy/loader";
99
import type { PolicyContext } from "../policy/types";
10+
import { isNativePlugin } from "../policy/types";
1011

1112
import { parseVscodeLocations } from "./checkers";
1213
import { buildCriteria } from "./criteria";
@@ -90,14 +91,33 @@ export async function runReadinessReport(options: ReadinessOptions): Promise<Rea
9091

9192
if (policySources?.length) {
9293
const policyConfigs: PolicyConfig[] = [];
94+
let hasNativePlugin = false;
9395
for (const source of policySources) {
94-
policyConfigs.push(await loadPolicy(source, { jsonOnly: isConfigSourced }));
96+
const loaded = await loadPolicy(source, { jsonOnly: isConfigSourced });
97+
// Native PolicyPlugin exports are handled by the engine path (loadPluginChain).
98+
// Skip them here — they'll be loaded by loadPluginChain below.
99+
if (isNativePlugin(loaded)) {
100+
hasNativePlugin = true;
101+
continue;
102+
}
103+
policyConfigs.push(loaded);
104+
}
105+
if (policyConfigs.length > 0) {
106+
const resolved = resolveChain(baseCriteria, baseExtras, policyConfigs);
107+
resolvedCriteria = resolved.criteria;
108+
resolvedExtras = resolved.extras;
109+
passRateThreshold = resolved.thresholds.passRate;
110+
policyInfo = { chain: resolved.chain, criteriaCount: resolved.criteria.length };
111+
} else {
112+
resolvedCriteria = baseCriteria;
113+
resolvedExtras = baseExtras;
114+
}
115+
// When native plugins are present, automatically enable the engine path
116+
// so their detectors, hooks, and recommenders execute.
117+
// Use a local copy to avoid mutating the caller's options object.
118+
if (hasNativePlugin && !options.shadow) {
119+
options = { ...options, shadow: true };
95120
}
96-
const resolved = resolveChain(baseCriteria, baseExtras, policyConfigs);
97-
resolvedCriteria = resolved.criteria;
98-
resolvedExtras = resolved.extras;
99-
passRateThreshold = resolved.thresholds.passRate;
100-
policyInfo = { chain: resolved.chain, criteriaCount: resolved.criteria.length };
101121
} else {
102122
resolvedCriteria = baseCriteria;
103123
resolvedExtras = baseExtras;

0 commit comments

Comments
 (0)