@@ -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. */
0 commit comments