canary-test-cli 5.15.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Project test-fixture / helper symbol scanner — faithful TS port of
3
+ * `agent/core/fixture_scanner.py`.
4
+ *
5
+ * Extracts named exports from files under conventional fixture/helper dirs so
6
+ * downstream generation imports real identifiers. Regex-based, no AST.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
9
+ import { relative, resolve } from 'node:path';
10
+ const FIXTURE_DIRS = [
11
+ 'tests/fixtures',
12
+ 'tests/test-utils',
13
+ 'tests/helpers',
14
+ 'tests/support',
15
+ 'test/fixtures',
16
+ 'test/test-utils',
17
+ 'test/helpers',
18
+ 'e2e/fixtures',
19
+ 'e2e/helpers',
20
+ '__tests__/fixtures',
21
+ '__tests__/helpers',
22
+ 'fixtures',
23
+ 'test-utils',
24
+ ];
25
+ const IGNORED_DIRS = new Set([
26
+ 'node_modules',
27
+ '.git',
28
+ '__pycache__',
29
+ '.venv',
30
+ 'venv',
31
+ 'dist',
32
+ 'build',
33
+ '.next',
34
+ '.nuxt',
35
+ 'coverage',
36
+ ]);
37
+ const SUPPORTED_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.py'];
38
+ const MAX_FILES = 20;
39
+ const MAX_FILE_BYTES = 32_768;
40
+ const MAX_SYMBOLS_PER_FILE = 12;
41
+ const TS_DECL_RE = /^\s*export\s+(?:default\s+)?(?:async\s+)?(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm;
42
+ const TS_NAMED_RE = /^\s*export\s*\{([^}]+)\}/gm;
43
+ const PY_DECL_RE = /^(?:def|class)\s+([A-Za-z][\w]*)/gm;
44
+ export function isEmpty(s) {
45
+ return Object.keys(s.byModule).length === 0;
46
+ }
47
+ export class FixtureScanner {
48
+ scan(projectRoot = '.') {
49
+ const root = resolve(projectRoot);
50
+ const result = { byModule: {}, filesScanned: 0 };
51
+ if (!existsSync(root))
52
+ return result;
53
+ const candidates = this.collectCandidates(root);
54
+ for (const path of candidates) {
55
+ let text = safeRead(path);
56
+ if (text === null)
57
+ continue;
58
+ if (text.length > MAX_FILE_BYTES)
59
+ text = text.slice(0, MAX_FILE_BYTES);
60
+ const symbols = path.endsWith('.py')
61
+ ? extractPython(text)
62
+ : extractTs(text);
63
+ if (symbols.length === 0)
64
+ continue;
65
+ const relPath = relative(root, path).split(/[/\\]/).join('/');
66
+ result.byModule[relPath] = symbols.slice(0, MAX_SYMBOLS_PER_FILE);
67
+ result.filesScanned += 1;
68
+ }
69
+ return result;
70
+ }
71
+ collectCandidates(root) {
72
+ const candidates = [];
73
+ const seen = new Set();
74
+ for (const rel of FIXTURE_DIRS) {
75
+ const fixtureDir = resolve(root, rel);
76
+ if (!isDir(fixtureDir) || seen.has(fixtureDir))
77
+ continue;
78
+ seen.add(fixtureDir);
79
+ for (const path of rglobFilesSorted(fixtureDir)) {
80
+ if (path.split(/[/\\]/).some((part) => IGNORED_DIRS.has(part)))
81
+ continue;
82
+ if (!SUPPORTED_EXTS.some((e) => path.endsWith(e)))
83
+ continue;
84
+ candidates.push(path);
85
+ if (candidates.length >= MAX_FILES)
86
+ return candidates;
87
+ }
88
+ if (candidates.length >= MAX_FILES)
89
+ break;
90
+ }
91
+ return candidates;
92
+ }
93
+ }
94
+ /** Recursive file list, sorted — mirrors Python's `sorted(dir.rglob("*"))`. */
95
+ function rglobFilesSorted(dir) {
96
+ const files = [];
97
+ const walk = (d) => {
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(d, { withFileTypes: true });
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ for (const entry of entries) {
106
+ const full = resolve(d, entry.name);
107
+ if (entry.isDirectory())
108
+ walk(full);
109
+ else if (entry.isFile())
110
+ files.push(full);
111
+ }
112
+ };
113
+ walk(dir);
114
+ return files.sort(cmp);
115
+ }
116
+ function extractTs(text) {
117
+ const seen = new Set();
118
+ const out = [];
119
+ const add = (nameRaw) => {
120
+ const name = nameRaw.trim();
121
+ if (!name || seen.has(name))
122
+ return;
123
+ seen.add(name);
124
+ out.push(name);
125
+ };
126
+ for (const m of text.matchAll(TS_DECL_RE))
127
+ add(m[1]);
128
+ for (const m of text.matchAll(TS_NAMED_RE)) {
129
+ for (const pieceRaw of m[1].split(',')) {
130
+ const piece = pieceRaw.trim();
131
+ if (!piece)
132
+ continue;
133
+ const asIdx = piece.indexOf(' as ');
134
+ if (asIdx !== -1)
135
+ add(piece.slice(asIdx + 4));
136
+ else
137
+ add(piece);
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+ function extractPython(text) {
143
+ const seen = new Set();
144
+ const out = [];
145
+ for (const m of text.matchAll(PY_DECL_RE)) {
146
+ const name = m[1];
147
+ if (seen.has(name))
148
+ continue;
149
+ seen.add(name);
150
+ out.push(name);
151
+ }
152
+ return out;
153
+ }
154
+ function isDir(path) {
155
+ try {
156
+ return statSync(path).isDirectory();
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ }
162
+ function cmp(a, b) {
163
+ return a < b ? -1 : a > b ? 1 : 0;
164
+ }
165
+ function safeRead(path) {
166
+ try {
167
+ return readFileSync(path, 'utf-8');
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ //# sourceMappingURL=fixture-scanner.js.map
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Framework Registry — queries the collection of supported testing frameworks.
3
+ *
4
+ * Faithful TypeScript port of `agent/core/framework_registry.py`. Reads the same
5
+ * `agent/frameworks/registry.json` the Python engine uses. The default path is
6
+ * resolved relative to this file (../../.. → repo root, then agent/frameworks).
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { dirname, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { def } from '../util/coalesce.js';
12
+ import { scaffoldableFrameworks } from './scaffolder.js';
13
+ /** Default registry path: <repo>/agent/frameworks/registry.json. */
14
+ export function defaultRegistryPath() {
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ // here = ts/src/core → repo root is three levels up.
17
+ return resolve(here, '..', '..', '..', 'agent', 'frameworks', 'registry.json');
18
+ }
19
+ export class FrameworkRegistry {
20
+ frameworks;
21
+ constructor(registryPath = defaultRegistryPath()) {
22
+ const raw = JSON.parse(readFileSync(registryPath, 'utf-8'));
23
+ this.frameworks = def(raw.frameworks, []);
24
+ }
25
+ getAllFrameworks() {
26
+ return this.frameworks;
27
+ }
28
+ getByCategory(category) {
29
+ return this.frameworks.filter((f) => f.category === category || def(f.categories, []).includes(category));
30
+ }
31
+ getPreferredByCategory(category) {
32
+ const frameworks = this.getByCategory(category);
33
+ const preferred = frameworks.find((f) => f.status === 'preferred');
34
+ if (preferred)
35
+ return preferred;
36
+ return frameworks[0] ?? null;
37
+ }
38
+ findByName(name) {
39
+ return this.frameworks.find((f) => f.name === name) ?? null;
40
+ }
41
+ executionInfo(name) {
42
+ const f = this.findByName(name);
43
+ if (f === null)
44
+ return null;
45
+ return {
46
+ execution_command: def(f.execution_command, null),
47
+ ci_flags: def(f.ci_flags, []),
48
+ };
49
+ }
50
+ /**
51
+ * Whether canary can actually run a framework's command (Python:
52
+ * `_is_runnable`). The executor substitutes **only** `{file}`, so a command is
53
+ * runnable iff it carries `{file}` (the test path is injected) or has no
54
+ * placeholder at all (a whole-suite runner). A command with a *different*
55
+ * placeholder (e.g. `{target}`) would reach the shell unsubstituted, so it is
56
+ * NOT counted as executable, however plausible the string looks.
57
+ *
58
+ * `if (!cmd)` reproduces Python's `if not cmd` - `null`/`undefined`/`""` all
59
+ * count as not-runnable.
60
+ */
61
+ static isRunnable(cmd) {
62
+ if (!cmd)
63
+ return false;
64
+ return cmd.includes('{file}') || !cmd.includes('{');
65
+ }
66
+ /**
67
+ * Honest, code-DERIVED support level for a framework - never the registry's
68
+ * subjective `status`/`maturity` prose (Python: `capabilities`).
69
+ *
70
+ * - `scaffold`: a Scaffolder template exists (canary can bootstrap it).
71
+ * - `execute`: the registry carries a command the executor can *run* (see
72
+ * {@link isRunnable} - not merely a non-empty string).
73
+ *
74
+ * The headline `tier` derives from these two signals: `full` (scaffold +
75
+ * execute), `executable` (execute only), or `catalog` (neither). Returns
76
+ * `null` for an unknown framework. Case-insensitive, matching
77
+ * `Scaffolder.scaffold`.
78
+ */
79
+ capabilities(name) {
80
+ if (!name)
81
+ return null;
82
+ const lowered = name.toLowerCase();
83
+ const f = this.findByName(lowered);
84
+ if (f === null)
85
+ return null;
86
+ const canScaffold = scaffoldableFrameworks().has(lowered);
87
+ const canExecute = FrameworkRegistry.isRunnable(f.execution_command);
88
+ let tier;
89
+ if (canScaffold && canExecute) {
90
+ tier = 'full';
91
+ }
92
+ else if (canExecute) {
93
+ tier = 'executable';
94
+ }
95
+ else {
96
+ tier = 'catalog';
97
+ }
98
+ return { scaffold: canScaffold, execute: canExecute, tier };
99
+ }
100
+ summaries() {
101
+ return this.frameworks.map((f) => {
102
+ // Python: `self.capabilities(f.get("name"))` - capabilities() lowercases.
103
+ const caps = this.capabilities(f.name);
104
+ return {
105
+ name: f.name,
106
+ category: def(f.category, null),
107
+ categories: def(f.categories, []),
108
+ languages: def(f.languages, []),
109
+ file_extensions: def(f.file_extensions, []),
110
+ execution_command: def(f.execution_command, null),
111
+ ci_flags: def(f.ci_flags, []),
112
+ status: def(f.status, null),
113
+ capabilities: caps,
114
+ // Python: `(caps or {}).get("tier")` - null when caps is null.
115
+ tier: caps === null ? null : caps.tier,
116
+ };
117
+ });
118
+ }
119
+ matchByLanguage(language) {
120
+ return this.frameworks.filter((f) => def(f.languages, []).includes(language));
121
+ }
122
+ }
123
+ //# sourceMappingURL=framework-registry.js.map
@@ -0,0 +1,218 @@
1
+ /**
2
+ * MCP server identifier validation for company knowledge.
3
+ *
4
+ * Faithful TypeScript port of `agent/core/mcp_validator.py`. Resolves which MCP
5
+ * server identifiers are registered in the current Claude Code session by
6
+ * scanning:
7
+ *
8
+ * 1. `<root>/.mcp.json` and `~/.mcp.json` — plain server keys (e.g. "harness")
9
+ * 2. Installed Claude Code plugins — plugin-prefixed keys
10
+ * Format: `plugin_{plugin_slug}_{server_key}`
11
+ * e.g. atlassian plugin, "atlassian" server → "plugin_atlassian_atlassian"
12
+ *
13
+ * No network calls are made; this is a local config scan only.
14
+ *
15
+ * Python→TS nuances:
16
+ * - **Home injection**: Python's tests `patch("...Path.home")`. The idiomatic
17
+ * TS seam is an optional `home` parameter (defaulting to `os.homedir()`),
18
+ * threaded down exactly where Python calls `Path.home()`. Production
19
+ * behavior is unchanged when omitted; this mirrors the sibling
20
+ * `overlays.ts`, which already takes a `home` parameter.
21
+ * - A non-object top-level `.mcp.json`/`settings.json` (e.g. a JSON array)
22
+ * would make Python's `data.get(...)` raise an *uncaught* `AttributeError`;
23
+ * we degrade to "no servers" instead (safer, and never observable via a
24
+ * well-formed config).
25
+ */
26
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { readJsonWithWarning } from './config-validation.js';
30
+ /** Result of validating one MCP server id (Python `MCPValidationResult`). */
31
+ export class MCPValidationResult {
32
+ server_id;
33
+ status; // "registered" | "not_found" | "plugin_disabled"
34
+ source; // where it was found (for display)
35
+ note;
36
+ constructor(serverId, status, source = '', note = '') {
37
+ this.server_id = serverId;
38
+ this.status = status;
39
+ this.source = source;
40
+ this.note = note;
41
+ }
42
+ }
43
+ /**
44
+ * Check each `serverId` against locally registered MCP sources.
45
+ *
46
+ * Returns one {@link MCPValidationResult} per id, in input order.
47
+ */
48
+ export function validateMcpServers(serverIds, root, home) {
49
+ const registry = buildRegistry(root ?? process.cwd(), home ?? homedir());
50
+ const results = [];
51
+ for (const sid of serverIds) {
52
+ const entry = registry.get(sid);
53
+ if (entry !== undefined) {
54
+ const [source, note, status] = entry;
55
+ results.push(new MCPValidationResult(sid, status, source, note));
56
+ }
57
+ else {
58
+ results.push(new MCPValidationResult(sid, 'not_found'));
59
+ }
60
+ }
61
+ return results;
62
+ }
63
+ /**
64
+ * Fail-loud (but non-blocking) config-validation pass for `.mcp.json`.
65
+ *
66
+ * `validateMcpServers` silently treats a malformed `.mcp.json` the same as an
67
+ * absent one (each server resolves to "not_found"). This is a separate, explicit
68
+ * pass a caller can run alongside it to surface the distinction: an
69
+ * existing-but-unparseable `.mcp.json` (project or home) returns a warning
70
+ * naming the file and the parse error. A genuinely absent `.mcp.json` is not a
71
+ * warning. Never raises.
72
+ */
73
+ export function collectConfigWarnings(root, home) {
74
+ const r = root ?? process.cwd();
75
+ const h = home ?? homedir();
76
+ const warnings = [];
77
+ for (const path of [join(r, '.mcp.json'), join(h, '.mcp.json')]) {
78
+ const [, warning] = readJsonWithWarning(path);
79
+ if (warning)
80
+ warnings.push(warning);
81
+ }
82
+ return warnings;
83
+ }
84
+ // ── registry builder ──────────────────────────────────────────────────────
85
+ /**
86
+ * Return a `Map` of `server_id -> [source_label, note, status]` for all locally
87
+ * known MCP servers (Python `_build_registry`).
88
+ */
89
+ export function buildRegistry(root, home = homedir()) {
90
+ const registry = new Map();
91
+ // 1. Project-local .mcp.json
92
+ ingestMcpJson(join(root, '.mcp.json'), 'project .mcp.json', registry);
93
+ // 2. Home-dir .mcp.json
94
+ ingestMcpJson(join(home, '.mcp.json'), '~/.mcp.json', registry);
95
+ // 3. Installed Claude Code plugins
96
+ ingestPlugins(registry, home);
97
+ return registry;
98
+ }
99
+ function ingestMcpJson(path, label, registry) {
100
+ if (!existsSync(path))
101
+ return;
102
+ let data;
103
+ try {
104
+ data = JSON.parse(readFileSync(path, 'utf-8'));
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ // INTENTIONAL DIVERGENCE from the oracle: on a valid-JSON-but-non-object top
110
+ // level (e.g. a bare array `[]`), Python calls `data.get("mcpServers", {})`
111
+ // on a list → uncaught AttributeError, crashing the caller. We degrade to
112
+ // "no servers" instead — safer, never reachable via a well-formed config, and
113
+ // consistent with the accepted-divergence notes in config-validation.ts /
114
+ // coverage.ts. Pinned by a regression test.
115
+ const servers = isRecord(data) ? data['mcpServers'] : undefined;
116
+ if (!isRecord(servers))
117
+ return;
118
+ for (const key of Object.keys(servers)) {
119
+ if (!registry.has(key))
120
+ registry.set(key, [label, '', 'registered']);
121
+ }
122
+ }
123
+ /**
124
+ * Scan installed Claude Code plugins and derive their MCP server identifiers.
125
+ */
126
+ function ingestPlugins(registry, home) {
127
+ const pluginsCache = join(home, '.claude', 'plugins', 'cache');
128
+ if (!isDir(pluginsCache))
129
+ return;
130
+ const enabled = loadEnabledPlugins(home);
131
+ for (const marketplaceSlug of listDirs(pluginsCache)) {
132
+ const marketplacePath = join(pluginsCache, marketplaceSlug);
133
+ for (const pluginSlug of listDirs(marketplacePath)) {
134
+ const pluginPath = join(marketplacePath, pluginSlug);
135
+ const pluginKey = `${pluginSlug}@${marketplaceSlug}`;
136
+ const isEnabled = enabled[pluginKey] ?? false;
137
+ // Each plugin may have multiple version dirs; use the most recent.
138
+ const versionDirs = listDirs(pluginPath)
139
+ .map((name) => ({
140
+ name,
141
+ mtime: statSync(join(pluginPath, name)).mtimeMs,
142
+ }))
143
+ .sort((a, b) => b.mtime - a.mtime);
144
+ for (const vd of versionDirs) {
145
+ const mcpJson = join(pluginPath, vd.name, '.mcp.json');
146
+ if (!existsSync(mcpJson))
147
+ continue;
148
+ let data;
149
+ try {
150
+ data = JSON.parse(readFileSync(mcpJson, 'utf-8'));
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ const servers = isRecord(data) ? data['mcpServers'] : undefined;
156
+ if (isRecord(servers)) {
157
+ for (const serverKey of Object.keys(servers)) {
158
+ // Claude Code tool namespace: plugin_{plugin_slug}_{server_key}
159
+ const derivedId = `plugin_${pluginSlug}_${serverKey}`;
160
+ if (registry.has(derivedId))
161
+ continue;
162
+ const status = isEnabled ? 'registered' : 'plugin_disabled';
163
+ const note = isEnabled
164
+ ? ''
165
+ : `plugin ${pyRepr(pluginKey)} is installed but not enabled`;
166
+ const source = `plugin ${pyRepr(pluginKey)}`;
167
+ registry.set(derivedId, [source, note, status]);
168
+ }
169
+ }
170
+ break; // only inspect the most recent version dir that has .mcp.json
171
+ }
172
+ }
173
+ }
174
+ }
175
+ /** Load `enabledPlugins` from `~/.claude/settings.json`. */
176
+ function loadEnabledPlugins(home) {
177
+ const settingsPath = join(home, '.claude', 'settings.json');
178
+ try {
179
+ const data = JSON.parse(readFileSync(settingsPath, 'utf-8'));
180
+ if (isRecord(data)) {
181
+ const enabled = data['enabledPlugins'];
182
+ if (isRecord(enabled))
183
+ return enabled;
184
+ }
185
+ }
186
+ catch {
187
+ // OSError / JSON parse error → empty map.
188
+ }
189
+ return {};
190
+ }
191
+ // ── small helpers ─────────────────────────────────────────────────────────
192
+ function isRecord(value) {
193
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
194
+ }
195
+ function isDir(path) {
196
+ try {
197
+ return statSync(path).isDirectory();
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ }
203
+ /** Sorted directory names under `path` that are themselves directories. */
204
+ function listDirs(path) {
205
+ try {
206
+ return readdirSync(path, { withFileTypes: true })
207
+ .filter((e) => e.isDirectory())
208
+ .map((e) => e.name);
209
+ }
210
+ catch {
211
+ return [];
212
+ }
213
+ }
214
+ /** Python `repr()` of a simple string: single-quote wrapped. */
215
+ function pyRepr(s) {
216
+ return `'${s}'`;
217
+ }
218
+ //# sourceMappingURL=mcp-validator.js.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Project metadata scanner — faithful TS port of
3
+ * `agent/core/metadata_scanner.py`.
4
+ *
5
+ * Scans a project root for package.json / requirements.txt / pyproject.toml /
6
+ * tsconfig.json and surfaces dependency versions. Pure filesystem reads; no
7
+ * network, no execution.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+ export function isEmpty(m) {
12
+ return (Object.keys(m.jsDependencies).length === 0 &&
13
+ Object.keys(m.pythonPackages).length === 0 &&
14
+ Object.keys(m.tsconfig).length === 0);
15
+ }
16
+ /** Infer project languages from detected metadata (matches Python's set). */
17
+ export function detectedLanguages(m) {
18
+ const langs = new Set();
19
+ if (Object.keys(m.jsDependencies).length > 0) {
20
+ langs.add('typescript');
21
+ langs.add('javascript');
22
+ }
23
+ if (Object.keys(m.tsconfig).length > 0)
24
+ langs.add('typescript');
25
+ if (Object.keys(m.pythonPackages).length > 0)
26
+ langs.add('python');
27
+ return langs;
28
+ }
29
+ const _VERSION_SEPARATORS = ['==', '>=', '<=', '~=', '!=', '>', '<'];
30
+ export class MetadataScanner {
31
+ scan(projectRoot = '.') {
32
+ const root = resolve(projectRoot);
33
+ const metadata = {
34
+ jsDependencies: {},
35
+ pythonPackages: {},
36
+ tsconfig: {},
37
+ };
38
+ this.readPackageJson(root, metadata);
39
+ this.readRequirementsTxt(root, metadata);
40
+ if (Object.keys(metadata.pythonPackages).length === 0) {
41
+ this.readPyprojectToml(root, metadata);
42
+ }
43
+ this.readTsconfig(root, metadata);
44
+ return metadata;
45
+ }
46
+ readPackageJson(root, metadata) {
47
+ const pkg = readJson(resolve(root, 'package.json'));
48
+ if (!pkg)
49
+ return;
50
+ const deps = {};
51
+ const rawDeps = pkg['dependencies'];
52
+ const rawDev = pkg['devDependencies'];
53
+ if (isPlainObject(rawDeps))
54
+ Object.assign(deps, rawDeps);
55
+ if (isPlainObject(rawDev))
56
+ Object.assign(deps, rawDev);
57
+ metadata.jsDependencies = deps;
58
+ }
59
+ readRequirementsTxt(root, metadata) {
60
+ const text = readText(resolve(root, 'requirements.txt'));
61
+ if (text === null)
62
+ return;
63
+ metadata.pythonPackages = parseRequirements(text);
64
+ }
65
+ readPyprojectToml(root, metadata) {
66
+ const text = readText(resolve(root, 'pyproject.toml'));
67
+ if (text === null)
68
+ return;
69
+ metadata.pythonPackages = parsePyprojectDeps(text);
70
+ }
71
+ readTsconfig(root, metadata) {
72
+ const cfg = readJson(resolve(root, 'tsconfig.json'));
73
+ if (!cfg)
74
+ return;
75
+ const opts = cfg['compilerOptions'];
76
+ metadata.tsconfig = isPlainObject(opts) ? opts : {};
77
+ }
78
+ }
79
+ function splitDependency(spec) {
80
+ for (const sep of _VERSION_SEPARATORS) {
81
+ const idx = spec.indexOf(sep);
82
+ if (idx !== -1) {
83
+ const name = spec.slice(0, idx);
84
+ const version = spec.slice(idx + sep.length);
85
+ return [name.trim(), sep + version.trim()];
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ export function parseRequirements(text) {
91
+ const packages = {};
92
+ for (const raw of text.split('\n')) {
93
+ const line = raw.trim();
94
+ if (!line || line.startsWith('#') || line.startsWith('-'))
95
+ continue;
96
+ const split = splitDependency(line);
97
+ if (split)
98
+ packages[split[0]] = split[1];
99
+ else
100
+ packages[line] = '';
101
+ }
102
+ return packages;
103
+ }
104
+ export function parsePyprojectDeps(text) {
105
+ const section = text.match(/^\[project\]$([\s\S]*?)(?=^\[|$(?![\s\S]))/m);
106
+ if (!section)
107
+ return {};
108
+ const deps = section[1].match(/^dependencies\s*=\s*\[([\s\S]*?)\]/m);
109
+ if (!deps)
110
+ return {};
111
+ const packages = {};
112
+ for (const m of deps[1].matchAll(/"([^"]+)"/g)) {
113
+ const depStr = m[1];
114
+ const split = splitDependency(depStr);
115
+ if (split)
116
+ packages[split[0]] = split[1];
117
+ else if (depStr.trim())
118
+ packages[depStr.trim()] = '';
119
+ }
120
+ return packages;
121
+ }
122
+ function readText(path) {
123
+ if (!existsSync(path))
124
+ return null;
125
+ try {
126
+ return readFileSync(path, 'utf-8');
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ function readJson(path) {
133
+ const text = readText(path);
134
+ if (text === null)
135
+ return null;
136
+ try {
137
+ const parsed = JSON.parse(text);
138
+ return isPlainObject(parsed) ? parsed : null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ function isPlainObject(v) {
145
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
146
+ }
147
+ //# sourceMappingURL=metadata-scanner.js.map