@wrongstack/plugins 0.281.3 → 0.282.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 (78) hide show
  1. package/README.md +29 -3
  2. package/dist/accessibility-auditor.d.ts +40 -0
  3. package/dist/accessibility-auditor.js +411 -0
  4. package/dist/agent-handoff.d.ts +37 -0
  5. package/dist/agent-handoff.js +298 -0
  6. package/dist/api-compatibility-gate.d.ts +38 -0
  7. package/dist/api-compatibility-gate.js +357 -0
  8. package/dist/auto-i18n-extractor.d.ts +36 -0
  9. package/dist/auto-i18n-extractor.js +335 -0
  10. package/dist/checkpoint.js +18 -0
  11. package/dist/code-metrics.d.ts +31 -0
  12. package/dist/code-metrics.js +338 -0
  13. package/dist/commit-validator.js +67 -12
  14. package/dist/cost-tracker.js +58 -16
  15. package/dist/dead-code-detector.d.ts +34 -0
  16. package/dist/dead-code-detector.js +354 -0
  17. package/dist/dep-guard.js +47 -6
  18. package/dist/dependency-vulnerability-gate.d.ts +35 -0
  19. package/dist/dependency-vulnerability-gate.js +308 -0
  20. package/dist/diff-summary.js +97 -10
  21. package/dist/doc-sync-guard.d.ts +33 -0
  22. package/dist/doc-sync-guard.js +223 -0
  23. package/dist/duplicate-code-detector.d.ts +33 -0
  24. package/dist/duplicate-code-detector.js +384 -0
  25. package/dist/feature-flag-tracker.d.ts +38 -0
  26. package/dist/feature-flag-tracker.js +316 -0
  27. package/dist/file-watcher.js +85 -36
  28. package/dist/format-on-save.js +76 -10
  29. package/dist/import-organizer.js +73 -14
  30. package/dist/index.d.ts +27 -0
  31. package/dist/index.js +14067 -5054
  32. package/dist/interface-contract-guard.d.ts +37 -0
  33. package/dist/interface-contract-guard.js +302 -0
  34. package/dist/knowledge-graph.d.ts +45 -0
  35. package/dist/knowledge-graph.js +325 -0
  36. package/dist/license-audit-gate.d.ts +34 -0
  37. package/dist/license-audit-gate.js +260 -0
  38. package/dist/llm-cache.js +5 -0
  39. package/dist/loop-breaker.d.ts +0 -38
  40. package/dist/loop-breaker.js +209 -8
  41. package/dist/migration-planner.d.ts +30 -0
  42. package/dist/migration-planner.js +349 -0
  43. package/dist/model-router.js +5 -0
  44. package/dist/performance-regression-gate.d.ts +33 -0
  45. package/dist/performance-regression-gate.js +315 -0
  46. package/dist/plugin-stack-observer.d.ts +35 -0
  47. package/dist/plugin-stack-observer.js +125 -0
  48. package/dist/pr-drafter.d.ts +35 -0
  49. package/dist/pr-drafter.js +334 -0
  50. package/dist/prompt-firewall.js +5 -0
  51. package/dist/refactor-suggester.d.ts +38 -0
  52. package/dist/refactor-suggester.js +382 -0
  53. package/dist/release-notes-generator.d.ts +27 -0
  54. package/dist/release-notes-generator.js +209 -0
  55. package/dist/schema-evolution-guard.d.ts +42 -0
  56. package/dist/schema-evolution-guard.js +319 -0
  57. package/dist/security-hotspot-scanner.d.ts +30 -0
  58. package/dist/security-hotspot-scanner.js +402 -0
  59. package/dist/semantic-search-indexer.d.ts +38 -0
  60. package/dist/semantic-search-indexer.js +436 -0
  61. package/dist/shell-check.js +38 -3
  62. package/dist/smart-rename.d.ts +26 -0
  63. package/dist/smart-rename.js +170 -0
  64. package/dist/spec-linker.js +273 -133
  65. package/dist/test-coverage-gate.d.ts +37 -0
  66. package/dist/test-coverage-gate.js +263 -0
  67. package/dist/test-flake-detector.d.ts +27 -0
  68. package/dist/test-flake-detector.js +274 -0
  69. package/dist/test-generator.d.ts +32 -0
  70. package/dist/test-generator.js +243 -0
  71. package/dist/test-runner-gate.js +154 -22
  72. package/dist/todo-listener.d.ts +2 -2
  73. package/dist/todo-listener.js +5 -5
  74. package/dist/token-throttle.js +5 -0
  75. package/dist/type-gate.d.ts +37 -0
  76. package/dist/type-gate.js +311 -0
  77. package/package.json +112 -4
  78. package/LICENSE +0 -21
@@ -0,0 +1,316 @@
1
+ import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
2
+ import { resolve, isAbsolute, relative, extname } from 'path';
3
+
4
+ // src/feature-flag-tracker/index.ts
5
+ var API_VERSION = "^0.1.10";
6
+ var state = {
7
+ scanCount: 0,
8
+ flagCount: 0,
9
+ hookInvocationCount: 0,
10
+ warningCount: 0,
11
+ errorCount: 0,
12
+ hookUnregister: null
13
+ };
14
+ var DEFAULT_PATTERNS = [
15
+ String.raw`isFeatureEnabled\(['"]([^'"]+)['"]\)`,
16
+ String.raw`featureFlags?\.([A-Za-z_$][A-Za-z0-9_$]*)`,
17
+ String.raw`useFeatureFlag\(['"]([^'"]+)['"]\)`,
18
+ String.raw`flags\.([A-Za-z_$][A-Za-z0-9_$]*)`
19
+ ];
20
+ var DEFAULTS = {
21
+ enabled: false,
22
+ extensions: [".ts", ".tsx", ".js", ".jsx"],
23
+ patterns: [],
24
+ maxFindings: 50
25
+ };
26
+ function readConfig(raw) {
27
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
28
+ const r = raw;
29
+ return {
30
+ enabled: r["enabled"] !== false,
31
+ extensions: Array.isArray(r["extensions"]) ? r["extensions"].filter((x) => typeof x === "string") : DEFAULTS.extensions,
32
+ patterns: Array.isArray(r["patterns"]) ? r["patterns"].filter((x) => typeof x === "string") : DEFAULTS.patterns,
33
+ maxFindings: typeof r["maxFindings"] === "number" && r["maxFindings"] >= 1 && r["maxFindings"] <= 500 ? r["maxFindings"] : DEFAULTS.maxFindings
34
+ };
35
+ }
36
+ function withinProject(p) {
37
+ if (typeof p !== "string" || p.length === 0 || p.length > 4096) return false;
38
+ const root = process.cwd();
39
+ const resolved = isAbsolute(p) ? resolve(p) : resolve(root, p);
40
+ const rel = relative(root, resolved);
41
+ if (rel === "" || rel === ".") return true;
42
+ if (rel.startsWith("..")) return false;
43
+ if (isAbsolute(rel)) return false;
44
+ return true;
45
+ }
46
+ function normalizeExtensions(exts) {
47
+ return exts.map((e) => e.startsWith(".") ? e.toLowerCase() : `.${e.toLowerCase()}`);
48
+ }
49
+ function matchesExtension(p, exts) {
50
+ return exts.includes(extname(p).toLowerCase());
51
+ }
52
+ function collectSourceFiles(root, exts) {
53
+ const files = [];
54
+ if (!existsSync(root)) return files;
55
+ const s = statSync(root);
56
+ if (s.isFile()) {
57
+ if (matchesExtension(root, exts)) files.push(root);
58
+ return files;
59
+ }
60
+ if (!s.isDirectory()) return files;
61
+ function walk(dir) {
62
+ let entries;
63
+ try {
64
+ entries = readdirSync(dir);
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const entry of entries) {
69
+ if (entry === "node_modules" || entry === "dist" || entry === ".git" || entry === "coverage") continue;
70
+ const full = resolve(dir, entry);
71
+ let st;
72
+ try {
73
+ st = statSync(full);
74
+ } catch {
75
+ continue;
76
+ }
77
+ if (st.isDirectory()) {
78
+ walk(full);
79
+ } else if (st.isFile() && matchesExtension(full, exts)) {
80
+ files.push(full);
81
+ }
82
+ }
83
+ }
84
+ walk(root);
85
+ return files;
86
+ }
87
+ function toPosix(p) {
88
+ return p.replace(/\\/g, "/");
89
+ }
90
+ function relativePath(p) {
91
+ return toPosix(relative(process.cwd(), p));
92
+ }
93
+ function compilePatterns(patterns) {
94
+ const sources = [...DEFAULT_PATTERNS, ...patterns];
95
+ return sources.map((src) => new RegExp(src, "g"));
96
+ }
97
+ function scanFile(filePath, content, patterns, maxFindings) {
98
+ const usages = [];
99
+ const lines = content.split(/\r?\n/);
100
+ for (const re of patterns) {
101
+ re.lastIndex = 0;
102
+ let m;
103
+ while ((m = re.exec(content)) !== null) {
104
+ const flag = m[1] ?? m[0];
105
+ const lineNo = content.slice(0, m.index).split(/\r?\n/).length;
106
+ const context = (lines[lineNo - 1] ?? "").trim();
107
+ usages.push({
108
+ flag,
109
+ file: relativePath(filePath),
110
+ line: lineNo,
111
+ context,
112
+ pattern: re.source
113
+ });
114
+ if (usages.length >= maxFindings) break;
115
+ }
116
+ if (usages.length >= maxFindings) break;
117
+ }
118
+ return usages.slice(0, maxFindings);
119
+ }
120
+ function scanPath(rawPath, cfg) {
121
+ const root = process.cwd();
122
+ const resolved = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
123
+ const exts = normalizeExtensions(cfg.extensions);
124
+ const files = collectSourceFiles(resolved, exts);
125
+ const patterns = compilePatterns(cfg.patterns);
126
+ const usages = [];
127
+ for (const p of files) {
128
+ try {
129
+ const content = readFileSync(p, "utf-8");
130
+ usages.push(...scanFile(p, content, patterns, cfg.maxFindings));
131
+ if (usages.length >= cfg.maxFindings) break;
132
+ } catch {
133
+ }
134
+ }
135
+ return { usages: usages.slice(0, cfg.maxFindings), scannedFiles: files.length };
136
+ }
137
+ var plugin = {
138
+ name: "feature-flag-tracker",
139
+ version: "0.1.0",
140
+ description: "Scans source files for feature-flag-like expressions and reports usages",
141
+ apiVersion: API_VERSION,
142
+ capabilities: { tools: true, hooks: true },
143
+ defaultConfig: { ...DEFAULTS },
144
+ configSchema: {
145
+ type: "object",
146
+ properties: {
147
+ enabled: { type: "boolean", default: true, description: "Master switch." },
148
+ extensions: {
149
+ type: "array",
150
+ items: { type: "string" },
151
+ default: [".ts", ".tsx", ".js", ".jsx"],
152
+ description: "File extensions to scan."
153
+ },
154
+ patterns: {
155
+ type: "array",
156
+ items: { type: "string" },
157
+ default: [],
158
+ description: "Extra regex patterns (merged with built-in defaults)."
159
+ },
160
+ maxFindings: {
161
+ type: "number",
162
+ minimum: 1,
163
+ maximum: 500,
164
+ default: 50,
165
+ description: "Maximum flag usages reported per scan."
166
+ }
167
+ }
168
+ },
169
+ setup(api) {
170
+ state.scanCount = 0;
171
+ state.flagCount = 0;
172
+ state.hookInvocationCount = 0;
173
+ state.warningCount = 0;
174
+ state.errorCount = 0;
175
+ if (state.hookUnregister) {
176
+ try {
177
+ state.hookUnregister();
178
+ } catch {
179
+ }
180
+ state.hookUnregister = null;
181
+ }
182
+ const cfg = readConfig(api.config.extensions?.["feature-flag-tracker"]);
183
+ const hook = (input) => {
184
+ if (!cfg.enabled) return;
185
+ if (input.toolResult?.isError) return;
186
+ const inp = input.toolInput ?? {};
187
+ const sourcePath = inp["path"];
188
+ if (!sourcePath || typeof sourcePath !== "string") return;
189
+ if (!withinProject(sourcePath)) return;
190
+ const exts = normalizeExtensions(cfg.extensions);
191
+ if (!matchesExtension(sourcePath, exts)) return;
192
+ state.hookInvocationCount += 1;
193
+ const resolved = resolve(process.cwd(), sourcePath);
194
+ let content;
195
+ try {
196
+ content = readFileSync(resolved, "utf-8");
197
+ } catch {
198
+ state.errorCount += 1;
199
+ return;
200
+ }
201
+ const patterns = compilePatterns(cfg.patterns);
202
+ const usages = scanFile(resolved, content, patterns, cfg.maxFindings);
203
+ if (usages.length === 0) return;
204
+ state.warningCount += usages.length;
205
+ const flags = [...new Set(usages.map((u) => u.flag))].join(", ");
206
+ return {
207
+ additionalContext: `
208
+ \u{1F6A9} feature-flag-tracker: ${sourcePath} references feature flag(s): ${flags}.
209
+ Make sure flag behavior is intentional and consider updating flag inventory/docs.`
210
+ };
211
+ };
212
+ state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook);
213
+ api.tools.register({
214
+ name: "scan_feature_flags",
215
+ description: "Scan source files for feature-flag-like expressions (isFeatureEnabled, featureFlags.*, useFeatureFlag, flags.*, plus custom patterns).",
216
+ inputSchema: {
217
+ type: "object",
218
+ properties: {
219
+ path: { type: "string", default: ".", description: "File or directory path to scan." }
220
+ }
221
+ },
222
+ permission: "auto",
223
+ category: "Diagnostics",
224
+ mutating: false,
225
+ async execute(input) {
226
+ if (!cfg.enabled) return { ok: false, error: "feature-flag-tracker is disabled" };
227
+ const rawPath = typeof input.path === "string" ? input.path : ".";
228
+ if (!withinProject(rawPath)) {
229
+ return { ok: false, error: "path is outside the project root" };
230
+ }
231
+ state.scanCount += 1;
232
+ let result;
233
+ try {
234
+ result = scanPath(rawPath, cfg);
235
+ } catch (err) {
236
+ state.errorCount += 1;
237
+ return { ok: false, error: String(err) };
238
+ }
239
+ state.flagCount += result.usages.length;
240
+ return {
241
+ ok: true,
242
+ path: relativePath(resolve(process.cwd(), rawPath)),
243
+ scannedFiles: result.scannedFiles,
244
+ usages: result.usages
245
+ };
246
+ }
247
+ });
248
+ api.tools.register({
249
+ name: "feature_flag_status",
250
+ description: "Reports feature-flag-tracker state: config + counters.",
251
+ inputSchema: { type: "object", properties: {} },
252
+ permission: "auto",
253
+ category: "Diagnostics",
254
+ mutating: false,
255
+ async execute() {
256
+ return {
257
+ ok: true,
258
+ enabled: cfg.enabled,
259
+ extensions: cfg.extensions,
260
+ patterns: cfg.patterns,
261
+ maxFindings: cfg.maxFindings,
262
+ counters: {
263
+ scans: state.scanCount,
264
+ flags: state.flagCount,
265
+ hookInvocations: state.hookInvocationCount,
266
+ warnings: state.warningCount,
267
+ errors: state.errorCount
268
+ }
269
+ };
270
+ }
271
+ });
272
+ api.log.info("feature-flag-tracker plugin loaded", {
273
+ version: "0.1.0",
274
+ extensions: cfg.extensions,
275
+ customPatterns: cfg.patterns.length
276
+ });
277
+ },
278
+ teardown(api) {
279
+ if (state.hookUnregister) {
280
+ try {
281
+ state.hookUnregister();
282
+ } catch {
283
+ }
284
+ state.hookUnregister = null;
285
+ }
286
+ const final = {
287
+ scans: state.scanCount,
288
+ flags: state.flagCount,
289
+ hookInvocations: state.hookInvocationCount,
290
+ warnings: state.warningCount,
291
+ errors: state.errorCount
292
+ };
293
+ state.scanCount = 0;
294
+ state.flagCount = 0;
295
+ state.hookInvocationCount = 0;
296
+ state.warningCount = 0;
297
+ state.errorCount = 0;
298
+ api.log.info("feature-flag-tracker: teardown complete", { final });
299
+ },
300
+ async health() {
301
+ return {
302
+ ok: state.errorCount === 0,
303
+ message: state.errorCount ? `feature-flag-tracker: ${state.errorCount} error(s)` : `feature-flag-tracker: ${state.scanCount} scan(s), ${state.flagCount} flag usage(s)`,
304
+ counters: {
305
+ scans: state.scanCount,
306
+ flags: state.flagCount,
307
+ hookInvocations: state.hookInvocationCount,
308
+ warnings: state.warningCount,
309
+ errors: state.errorCount
310
+ }
311
+ };
312
+ }
313
+ };
314
+ var feature_flag_tracker_default = plugin;
315
+
316
+ export { feature_flag_tracker_default as default };
@@ -1,8 +1,18 @@
1
1
  import { watch } from 'fs';
2
- import * as path from 'path';
2
+ import { isAbsolute, resolve, relative } from 'path';
3
3
 
4
4
  // src/file-watcher/index.ts
5
5
  var API_VERSION = "^0.1.10";
6
+ function withinProject(p) {
7
+ if (typeof p !== "string" || p.length === 0 || p.length > 4096) return false;
8
+ const root = process.cwd();
9
+ const resolved = isAbsolute(p) ? resolve(p) : resolve(root, p);
10
+ const rel = relative(root, resolved);
11
+ if (rel === "" || rel === ".") return true;
12
+ if (rel.startsWith("..")) return false;
13
+ if (isAbsolute(rel)) return false;
14
+ return true;
15
+ }
6
16
  var watch_idCounter = 0;
7
17
  function nextId() {
8
18
  return `watch_${++watch_idCounter}_${Date.now().toString(36)}`;
@@ -71,16 +81,30 @@ var plugin = {
71
81
  function debounceEvent(key, fn, ms) {
72
82
  const existing = debounceTimers.get(key);
73
83
  if (existing) clearTimeout(existing);
74
- debounceTimers.set(key, setTimeout(() => {
75
- debounceTimers.delete(key);
76
- fn();
77
- }, ms));
84
+ debounceTimers.set(
85
+ key,
86
+ setTimeout(() => {
87
+ debounceTimers.delete(key);
88
+ fn();
89
+ }, ms)
90
+ );
78
91
  }
79
92
  const autoIndex = api.config.extensions?.["file-watcher"]?.["autoIndex"] ?? false;
80
93
  const indexProjectRoot = api.config.extensions?.["file-watcher"]?.["indexProjectRoot"] ?? "";
94
+ const safeIndexRoot = indexProjectRoot !== "" && withinProject(indexProjectRoot) ? indexProjectRoot : "";
95
+ if (indexProjectRoot !== "" && safeIndexRoot === "") {
96
+ api.log.warn(
97
+ "file-watcher: indexProjectRoot is outside the project root \u2014 using watched dirPath instead",
98
+ {
99
+ indexProjectRoot
100
+ }
101
+ );
102
+ }
81
103
  const INDEXABLE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
82
104
  function isIndexableFile(filePath) {
83
- return INDEXABLE_EXTENSIONS.has(path.extname(filePath));
105
+ const dot = filePath.lastIndexOf(".");
106
+ const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
107
+ return INDEXABLE_EXTENSIONS.has(ext);
84
108
  }
85
109
  function safeWatchDir(dirPath, recursive, handle) {
86
110
  try {
@@ -88,34 +112,42 @@ var plugin = {
88
112
  if (!filename) return;
89
113
  const fullPath = `${dirPath}/${filename}`;
90
114
  const key = `${handle.id}:${fullPath}:${eventType}`;
91
- debounceEvent(key, () => {
92
- api.emitCustom("file-watcher:changed", {
93
- watch_id: handle.id,
94
- path: fullPath,
95
- event: eventType,
96
- filename,
97
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
98
- });
99
- api.metrics.counter("file_change", 1, { event: eventType ?? "unknown" });
100
- api.log.debug(`file-watcher: ${eventType} ${fullPath} (watch=${handle.id})`);
101
- if (autoIndex && isIndexableFile(fullPath)) {
102
- debounceEvent(`index:${fullPath}`, async () => {
103
- try {
104
- const { enqueueReindex } = await import('@wrongstack/tools/codebase-index');
105
- const root = indexProjectRoot || dirPath;
106
- enqueueReindex({
107
- projectRoot: root,
108
- files: [fullPath],
109
- onError: (err) => api.log.warn(`file-watcher: auto-index failed for ${fullPath}: ${err}`)
110
- });
111
- api.metrics.counter("index_file", 1);
112
- api.log.debug(`file-watcher: auto-index scheduled for ${fullPath}`);
113
- } catch (err) {
114
- api.log.warn(`file-watcher: auto-index failed for ${fullPath}: ${err}`);
115
- }
116
- }, debounceMs);
117
- }
118
- }, debounceMs);
115
+ debounceEvent(
116
+ key,
117
+ () => {
118
+ api.emitCustom("file-watcher:changed", {
119
+ watch_id: handle.id,
120
+ path: fullPath,
121
+ event: eventType,
122
+ filename,
123
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
124
+ });
125
+ api.metrics.counter("file_change", 1, { event: eventType ?? "unknown" });
126
+ api.log.debug(`file-watcher: ${eventType} ${fullPath} (watch=${handle.id})`);
127
+ if (autoIndex && isIndexableFile(fullPath)) {
128
+ debounceEvent(
129
+ `index:${fullPath}`,
130
+ async () => {
131
+ try {
132
+ const { enqueueReindex } = await import('@wrongstack/tools/codebase-index');
133
+ const root = safeIndexRoot || dirPath;
134
+ enqueueReindex({
135
+ projectRoot: root,
136
+ files: [fullPath],
137
+ onError: (err) => api.log.warn(`file-watcher: auto-index failed for ${fullPath}: ${err}`)
138
+ });
139
+ api.metrics.counter("index_file", 1);
140
+ api.log.debug(`file-watcher: auto-index scheduled for ${fullPath}`);
141
+ } catch (err) {
142
+ api.log.warn(`file-watcher: auto-index failed for ${fullPath}: ${err}`);
143
+ }
144
+ },
145
+ debounceMs
146
+ );
147
+ }
148
+ },
149
+ debounceMs
150
+ );
119
151
  });
120
152
  watcher.on("error", (err) => {
121
153
  api.log.warn(`file-watcher: error on ${dirPath}: ${err}`);
@@ -156,14 +188,31 @@ var plugin = {
156
188
  async execute(input) {
157
189
  const rawPaths = input["paths"];
158
190
  if (!rawPaths || typeof rawPaths !== "object" || !Array.isArray(rawPaths)) {
159
- return { ok: false, error: "paths must be an array of file/directory paths", watch_id: null };
191
+ return {
192
+ ok: false,
193
+ error: "paths must be an array of file/directory paths",
194
+ watch_id: null
195
+ };
160
196
  }
161
197
  const paths = rawPaths;
162
198
  if (paths.length === 0) {
163
- return { ok: false, error: "paths array is empty \u2014 provide at least one path", watch_id: null };
199
+ return {
200
+ ok: false,
201
+ error: "paths array is empty \u2014 provide at least one path",
202
+ watch_id: null
203
+ };
164
204
  }
165
205
  const events = input["events"] ?? ["change", "add", "delete"];
166
206
  const recursive = input["recursive"] ?? true;
207
+ const bad = paths.find((p) => !withinProject(p));
208
+ if (bad !== void 0) {
209
+ return {
210
+ ok: false,
211
+ error: `path is outside the project root: ${bad}`,
212
+ watch_id: null,
213
+ rejectedOutsideProject: true
214
+ };
215
+ }
167
216
  const id = nextId();
168
217
  const handle = {
169
218
  id,
@@ -1,8 +1,19 @@
1
- import { execSync } from 'child_process';
1
+ import { execSync, execFileSync } from 'child_process';
2
2
  import { existsSync, statSync } from 'fs';
3
+ import { isAbsolute, resolve, relative } from 'path';
3
4
 
4
5
  // src/format-on-save/index.ts
5
6
  var API_VERSION = "^0.1.10";
7
+ function withinProject(p) {
8
+ if (typeof p !== "string" || p.length === 0 || p.length > 4096) return false;
9
+ const root = process.cwd();
10
+ const resolved = isAbsolute(p) ? resolve(p) : resolve(root, p);
11
+ const rel = relative(root, resolved);
12
+ if (rel === "" || rel === ".") return true;
13
+ if (rel.startsWith("..")) return false;
14
+ if (isAbsolute(rel)) return false;
15
+ return true;
16
+ }
6
17
  var state = {
7
18
  invocationCount: 0,
8
19
  /** Times formatting was applied (file changed). */
@@ -11,6 +22,9 @@ var state = {
11
22
  cleanCount: 0,
12
23
  /** Times biome failed (not installed, timeout, parse error). */
13
24
  errorCount: 0,
25
+ /** Times the format pass was skipped because import-organizer
26
+ * had already covered the path within the TTL window. */
27
+ coveredSkipCount: 0,
14
28
  /** Hook handle for teardown. */
15
29
  hookUnregister: null,
16
30
  /** Last format result — surfaced by health() + status tool. */
@@ -18,17 +32,29 @@ var state = {
18
32
  };
19
33
  var DEFAULTS = {
20
34
  enabled: true,
21
- timeoutMs: 5e3
35
+ timeoutMs: 5e3,
36
+ skipWhenCoveredBy: true,
37
+ skipTtlMs: 3e4
22
38
  };
23
39
  function readConfig(raw) {
24
40
  if (!raw || typeof raw !== "object") return { ...DEFAULTS };
25
41
  const r = raw;
26
42
  return {
27
43
  enabled: r["enabled"] !== false,
28
- timeoutMs: typeof r["timeoutMs"] === "number" && r["timeoutMs"] > 0 ? r["timeoutMs"] : DEFAULTS.timeoutMs
44
+ timeoutMs: typeof r["timeoutMs"] === "number" && r["timeoutMs"] > 0 ? r["timeoutMs"] : DEFAULTS.timeoutMs,
45
+ skipWhenCoveredBy: r["skipWhenCoveredBy"] !== false,
46
+ skipTtlMs: typeof r["skipTtlMs"] === "number" && r["skipTtlMs"] >= 0 ? r["skipTtlMs"] : DEFAULTS.skipTtlMs
29
47
  };
30
48
  }
49
+ var recentlyCovered = /* @__PURE__ */ new Map();
50
+ function evictExpired(ttlMs) {
51
+ const cutoff = Date.now() - ttlMs;
52
+ for (const [path, ts] of recentlyCovered) {
53
+ if (ts < cutoff) recentlyCovered.delete(path);
54
+ }
55
+ }
31
56
  function formatFile(filePath, timeoutMs) {
57
+ if (!withinProject(filePath)) return null;
32
58
  if (!existsSync(filePath)) return null;
33
59
  let bytesBefore;
34
60
  try {
@@ -37,7 +63,7 @@ function formatFile(filePath, timeoutMs) {
37
63
  return null;
38
64
  }
39
65
  try {
40
- execSync(`npx biome format --write "${filePath}"`, {
66
+ execFileSync("npx", ["biome", "format", "--write", filePath], {
41
67
  encoding: "utf-8",
42
68
  timeout: timeoutMs,
43
69
  cwd: process.cwd(),
@@ -57,7 +83,7 @@ function formatFile(filePath, timeoutMs) {
57
83
  return { changed: true, bytesBefore, bytesAfter };
58
84
  }
59
85
  try {
60
- execSync(`npx biome format "${filePath}"`, {
86
+ execFileSync("npx", ["biome", "format", filePath], {
61
87
  encoding: "utf-8",
62
88
  timeout: timeoutMs,
63
89
  cwd: process.cwd(),
@@ -88,6 +114,17 @@ var plugin = {
88
114
  minimum: 1e3,
89
115
  default: 5e3,
90
116
  description: "Biome format process timeout in milliseconds."
117
+ },
118
+ skipWhenCoveredBy: {
119
+ type: "boolean",
120
+ default: true,
121
+ description: "Skip the format pass when another plugin (e.g. import-organizer) just touched the same path. Saves one biome invocation per write/edit when both plugins are enabled."
122
+ },
123
+ skipTtlMs: {
124
+ type: "number",
125
+ minimum: 0,
126
+ default: 3e4,
127
+ description: "How long (ms) to remember a path covered by another plugin. 0 disables the memory."
91
128
  }
92
129
  }
93
130
  },
@@ -96,8 +133,10 @@ var plugin = {
96
133
  state.formattedCount = 0;
97
134
  state.cleanCount = 0;
98
135
  state.errorCount = 0;
136
+ state.coveredSkipCount = 0;
99
137
  state.hookUnregister = null;
100
138
  state.lastResult = null;
139
+ recentlyCovered.clear();
101
140
  const cfg = readConfig(api.config.extensions?.["format-on-save"]);
102
141
  let biomeAvailable = false;
103
142
  try {
@@ -120,6 +159,20 @@ var plugin = {
120
159
  const inp = input.toolInput ?? {};
121
160
  const filePath = inp["path"];
122
161
  if (!filePath || typeof filePath !== "string") return;
162
+ if (cfg.skipWhenCoveredBy && cfg.skipTtlMs > 0) {
163
+ evictExpired(cfg.skipTtlMs);
164
+ if (recentlyCovered.has(filePath)) {
165
+ state.coveredSkipCount = (state.coveredSkipCount ?? 0) + 1;
166
+ recentlyCovered.delete(filePath);
167
+ api.log.info(
168
+ `format-on-save: skipped ${filePath} \u2014 already formatted by import-organizer`,
169
+ {
170
+ tool: toolName
171
+ }
172
+ );
173
+ return;
174
+ }
175
+ }
123
176
  state.invocationCount += 1;
124
177
  const result = formatFile(filePath, cfg.timeoutMs);
125
178
  if (!result) {
@@ -150,9 +203,15 @@ var plugin = {
150
203
  return;
151
204
  };
152
205
  state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook);
206
+ api.onPattern("import-organizer:done", (_eventName, payload) => {
207
+ const p = payload ?? {};
208
+ if (typeof p.path !== "string" || p.path.length === 0) return;
209
+ recentlyCovered.set(p.path, Date.now());
210
+ api.metrics.counter("covered_notice");
211
+ });
153
212
  api.tools.register({
154
213
  name: "format_on_save_status",
155
- description: "Reports format-on-save state: biome availability, and per-session formatted/clean/error counters.",
214
+ description: "Reports format-on-save state: biome availability, and per-session formatted/clean/error/skipped counters.",
156
215
  inputSchema: { type: "object", properties: {} },
157
216
  permission: "auto",
158
217
  category: "Code Quality",
@@ -163,11 +222,14 @@ var plugin = {
163
222
  enabled: cfg.enabled,
164
223
  biomeAvailable,
165
224
  timeoutMs: cfg.timeoutMs,
225
+ skipWhenCoveredBy: cfg.skipWhenCoveredBy,
226
+ skipTtlMs: cfg.skipTtlMs,
166
227
  counters: {
167
228
  invocations: state.invocationCount,
168
229
  formatted: state.formattedCount,
169
230
  clean: state.cleanCount,
170
- errors: state.errorCount
231
+ errors: state.errorCount,
232
+ coveredSkips: state.coveredSkipCount
171
233
  },
172
234
  lastResult: state.lastResult
173
235
  };
@@ -191,24 +253,28 @@ var plugin = {
191
253
  invocations: state.invocationCount,
192
254
  formatted: state.formattedCount,
193
255
  clean: state.cleanCount,
194
- errors: state.errorCount
256
+ errors: state.errorCount,
257
+ coveredSkips: state.coveredSkipCount
195
258
  };
196
259
  state.invocationCount = 0;
197
260
  state.formattedCount = 0;
198
261
  state.cleanCount = 0;
199
262
  state.errorCount = 0;
263
+ state.coveredSkipCount = 0;
200
264
  state.lastResult = null;
265
+ recentlyCovered.clear();
201
266
  api.log.info("format-on-save: teardown complete", { final });
202
267
  },
203
268
  async health() {
204
269
  return {
205
270
  ok: true,
206
- message: state.lastResult === null ? `format-on-save: ${state.invocationCount} invocation(s), ${state.formattedCount} formatted` : state.lastResult.changed ? `format-on-save: last formatted ${state.lastResult.path} (${state.lastResult.tool}) at ${state.lastResult.when}` : `format-on-save: last check on ${state.lastResult.path} was already clean`,
271
+ message: state.lastResult === null ? `format-on-save: ${state.invocationCount} invocation(s), ${state.formattedCount} formatted, ${state.coveredSkipCount} covered-skipped` : state.lastResult.changed ? `format-on-save: last formatted ${state.lastResult.path} (${state.lastResult.tool}) at ${state.lastResult.when}` : `format-on-save: last check on ${state.lastResult.path} was already clean`,
207
272
  counters: {
208
273
  invocations: state.invocationCount,
209
274
  formatted: state.formattedCount,
210
275
  clean: state.cleanCount,
211
- errors: state.errorCount
276
+ errors: state.errorCount,
277
+ coveredSkips: state.coveredSkipCount
212
278
  },
213
279
  lastResult: state.lastResult
214
280
  };