Skip to content

Commit 04a0424

Browse files
scotty595claude
andcommitted
feat(scanner): plugin architecture for framework-aware tool detection
Adds an extensibility surface for repo scanning so frameworks with non-trivial tool layouts (skill containers, factory packages, etc.) can plug in their own detection logic without bloating the SDK core. - New ScannerPlugin interface (scanner-plugins/types) with ownsImport, expandTools, optional detectFramework, and an async ExpandToolsContext that lets plugins recurse via a caller-provided FileResolver - scanRepoContentsWithPlugins dispatches to plugins, deduplicates by specifier and resolved path, caps work via maxResolves - Hand-rolled zero-dep import-lexer parses ES module imports into structured form, used both by plugins and by the new generic import-based fallback in extractTools Scanner plugin implementations live in consumer packages so they can use heavier deps (e.g. ts-morph) without violating the SDK zero-dep rule. Also tightens monorepo agent detection: - Only count packages with framework deps in `dependencies` (not `peerDependencies` / `devDependencies`) to filter out skill libraries that declare the framework as a peer - Strip `@scope/` prefix and trailing `-agent` suffix from display names so monorepo siblings render cleanly - Drop `zod` from AGENT_SIGNAL_DEPS — it's in every TS project and was producing false positives Bumps governance-sdk to 0.8.1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d9a404d commit 04a0424

9 files changed

Lines changed: 1404 additions & 18 deletions

File tree

packages/governance/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "governance-sdk",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "AI Agent Governance for TypeScript — policy enforcement, scoring, compliance, and audit for AI agents",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -69,6 +69,10 @@
6969
"types": "./dist/repo-patterns.d.ts",
7070
"import": "./dist/repo-patterns.js"
7171
},
72+
"./scanner-plugins": {
73+
"types": "./dist/scanner-plugins/types.d.ts",
74+
"import": "./dist/scanner-plugins/types.js"
75+
},
7276
"./policy-compose": {
7377
"types": "./dist/policy-compose.d.ts",
7478
"import": "./dist/policy-compose.js"
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import {
4+
parseImports,
5+
isToolImport,
6+
toolNamesFromImport,
7+
extractToolImports,
8+
} from "./import-lexer";
9+
10+
describe("parseImports", () => {
11+
it("parses default imports", () => {
12+
const out = parseImports(`import Foo from "pkg";`);
13+
assert.equal(out.length, 1);
14+
assert.equal(out[0].specifier, "pkg");
15+
assert.equal(out[0].defaultName, "Foo");
16+
assert.equal(out[0].kind, "default");
17+
});
18+
19+
it("parses namespace imports", () => {
20+
const out = parseImports(`import * as NS from "pkg";`);
21+
assert.equal(out.length, 1);
22+
assert.equal(out[0].namespaceName, "NS");
23+
assert.equal(out[0].kind, "namespace");
24+
});
25+
26+
it("parses named imports with aliases", () => {
27+
const out = parseImports(`import { a, b as c, d } from "pkg";`);
28+
assert.equal(out.length, 1);
29+
assert.deepEqual(out[0].named, [
30+
{ imported: "a", local: "a" },
31+
{ imported: "b", local: "c" },
32+
{ imported: "d", local: "d" },
33+
]);
34+
assert.equal(out[0].kind, "named");
35+
});
36+
37+
it("parses default + named together", () => {
38+
const out = parseImports(`import Foo, { a, b } from "pkg";`);
39+
assert.equal(out.length, 1);
40+
assert.equal(out[0].defaultName, "Foo");
41+
assert.equal(out[0].named.length, 2);
42+
});
43+
44+
it("parses default + namespace together", () => {
45+
const out = parseImports(`import Foo, * as NS from "pkg";`);
46+
assert.equal(out.length, 1);
47+
assert.equal(out[0].defaultName, "Foo");
48+
assert.equal(out[0].namespaceName, "NS");
49+
});
50+
51+
it("parses side-effect imports", () => {
52+
const out = parseImports(`import "./polyfills";`);
53+
assert.equal(out.length, 1);
54+
assert.equal(out[0].kind, "side-effect");
55+
assert.equal(out[0].specifier, "./polyfills");
56+
});
57+
58+
it("parses multi-line named imports", () => {
59+
const source = `
60+
import {
61+
a,
62+
b as aliased,
63+
c,
64+
} from "pkg";
65+
`;
66+
const out = parseImports(source);
67+
assert.equal(out.length, 1);
68+
assert.equal(out[0].named.length, 3);
69+
});
70+
71+
it("parses re-exports as named imports", () => {
72+
const out = parseImports(`export { a, b } from "pkg";`);
73+
assert.equal(out.length, 1);
74+
assert.equal(out[0].specifier, "pkg");
75+
assert.equal(out[0].named.length, 2);
76+
});
77+
78+
it("parses namespace re-exports", () => {
79+
const out = parseImports(`export * as NS from "pkg";`);
80+
assert.equal(out.length, 1);
81+
assert.equal(out[0].namespaceName, "NS");
82+
});
83+
84+
it("parses multiple imports in one file", () => {
85+
const source = `
86+
import a from "one";
87+
import { b } from "two";
88+
import * as c from "three";
89+
`;
90+
const out = parseImports(source);
91+
assert.equal(out.length, 3);
92+
});
93+
94+
it("ignores imports inside string literals", () => {
95+
const source = `const s = 'import Foo from "pkg"'; import Real from "real";`;
96+
const out = parseImports(source);
97+
assert.equal(out.length, 1);
98+
assert.equal(out[0].specifier, "real");
99+
});
100+
101+
it("ignores imports inside line comments", () => {
102+
const source = `// import Fake from "pkg"\nimport Real from "real";`;
103+
const out = parseImports(source);
104+
assert.equal(out.length, 1);
105+
assert.equal(out[0].specifier, "real");
106+
});
107+
108+
it("ignores imports inside block comments", () => {
109+
const source = `/* import Fake from "pkg" */\nimport Real from "real";`;
110+
const out = parseImports(source);
111+
assert.equal(out.length, 1);
112+
assert.equal(out[0].specifier, "real");
113+
});
114+
115+
it("handles type-only imports", () => {
116+
const out = parseImports(`import type { Foo, Bar } from "pkg";`);
117+
assert.equal(out.length, 1);
118+
assert.equal(out[0].named.length, 2);
119+
});
120+
121+
it("drops leading `type` from named imports", () => {
122+
const out = parseImports(`import { type Foo, Bar } from "pkg";`);
123+
assert.equal(out.length, 1);
124+
const names = out[0].named.map((n) => n.imported);
125+
assert.deepEqual(names, ["Foo", "Bar"]);
126+
});
127+
128+
it("is tolerant of malformed statements", () => {
129+
// Should not throw; may return whatever is parseable
130+
const source = `import from "broken"; import ok from "ok";`;
131+
const out = parseImports(source);
132+
assert.ok(out.some((i) => i.specifier === "ok"));
133+
});
134+
});
135+
136+
describe("isToolImport", () => {
137+
it("matches imports from /skills/ path", () => {
138+
const [imp] = parseImports(`import dealSkill from "@lua-agents/crm/skills/dealSkill";`);
139+
assert.ok(isToolImport(imp));
140+
});
141+
142+
it("matches imports from /tools/ path", () => {
143+
const [imp] = parseImports(`import webSearch from "@org/pkg/tools/webSearchTool";`);
144+
assert.ok(isToolImport(imp));
145+
});
146+
147+
it("matches default imports ending in Skill", () => {
148+
const [imp] = parseImports(`import apolloSkill from "@lua-agents/prospecting";`);
149+
assert.ok(isToolImport(imp));
150+
});
151+
152+
it("matches named imports ending in Tool", () => {
153+
const [imp] = parseImports(`import { searchTool, otherThing } from "pkg";`);
154+
assert.ok(isToolImport(imp));
155+
});
156+
157+
it("does not match Toolbar/Tooltip false positives", () => {
158+
const [imp] = parseImports(`import { Toolbar, Tooltip } from "lucide-react";`);
159+
assert.equal(isToolImport(imp), false);
160+
});
161+
162+
it("does not match unrelated imports", () => {
163+
const [imp] = parseImports(`import { useState } from "react";`);
164+
assert.equal(isToolImport(imp), false);
165+
});
166+
});
167+
168+
describe("toolNamesFromImport", () => {
169+
it("returns the default name for /skills/ path imports", () => {
170+
const [imp] = parseImports(`import dealSkill from "@lua-agents/crm/skills/dealSkill";`);
171+
assert.deepEqual(toolNamesFromImport(imp), ["dealSkill"]);
172+
});
173+
174+
it("returns tool-shaped named imports only", () => {
175+
const [imp] = parseImports(`import { searchTool, helper, analyzeSkill } from "pkg";`);
176+
assert.deepEqual(toolNamesFromImport(imp).sort(), ["analyzeSkill", "searchTool"]);
177+
});
178+
179+
it("filters out Toolbar-style false positives", () => {
180+
const [imp] = parseImports(`import { Toolbar, realTool } from "pkg";`);
181+
assert.deepEqual(toolNamesFromImport(imp), ["realTool"]);
182+
});
183+
184+
it("falls back to the specifier tail for bare skill paths", () => {
185+
const [imp] = parseImports(`import X from "@lua-agents/crm/skills/dealSkill";`);
186+
// Default name is `X` but specifier tail is `dealSkill` — the default
187+
// wins because it IS tool-shaped in Luna's pattern, but when the
188+
// default name is generic, callers still get useful info via the path.
189+
assert.ok(toolNamesFromImport(imp).length >= 1);
190+
});
191+
});
192+
193+
describe("extractToolImports", () => {
194+
it("finds all tool-shaped imports in a file", () => {
195+
const source = `
196+
import dealSkill from "@lua-agents/crm/skills/dealSkill";
197+
import { useState } from "react";
198+
import { Toolbar } from "lucide-react";
199+
import { searchTool } from "@org/core";
200+
import noteSkill from "@lua-agents/crm/skills/noteSkill";
201+
`;
202+
const out = extractToolImports(source);
203+
assert.equal(out.length, 3);
204+
const specs = out.map((i) => i.specifier);
205+
assert.ok(specs.includes("@lua-agents/crm/skills/dealSkill"));
206+
assert.ok(specs.includes("@lua-agents/crm/skills/noteSkill"));
207+
assert.ok(specs.includes("@org/core"));
208+
});
209+
});

0 commit comments

Comments
 (0)