@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,223 @@
1
+ import { isAbsolute, resolve, relative, basename, extname } from 'path';
2
+
3
+ // src/doc-sync-guard/index.ts
4
+ var API_VERSION = "^0.1.10";
5
+ var state = {
6
+ changedFiles: [],
7
+ sourceWrites: 0,
8
+ docWrites: 0,
9
+ warningsIssued: 0,
10
+ hookUnregister: null
11
+ };
12
+ var DEFAULTS = {
13
+ enabled: false,
14
+ sourceExtensions: [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"],
15
+ docNames: ["README.md", "README", "CONTRIBUTING.md", "CHANGELOG.md"],
16
+ maxTrackedFiles: 20
17
+ };
18
+ function readConfig(raw) {
19
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
20
+ const r = raw;
21
+ return {
22
+ enabled: r["enabled"] !== false,
23
+ sourceExtensions: Array.isArray(r["sourceExtensions"]) ? r["sourceExtensions"].filter((x) => typeof x === "string") : DEFAULTS.sourceExtensions,
24
+ docNames: Array.isArray(r["docNames"]) ? r["docNames"].filter((x) => typeof x === "string") : DEFAULTS.docNames,
25
+ maxTrackedFiles: typeof r["maxTrackedFiles"] === "number" && r["maxTrackedFiles"] >= 1 && r["maxTrackedFiles"] <= 200 ? r["maxTrackedFiles"] : DEFAULTS.maxTrackedFiles
26
+ };
27
+ }
28
+ function withinProject(p) {
29
+ if (typeof p !== "string" || p.length === 0 || p.length > 4096) return false;
30
+ const root = process.cwd();
31
+ const resolved = isAbsolute(p) ? resolve(p) : resolve(root, p);
32
+ const rel = relative(root, resolved);
33
+ if (rel === "" || rel === ".") return true;
34
+ if (rel.startsWith("..")) return false;
35
+ if (isAbsolute(rel)) return false;
36
+ return true;
37
+ }
38
+ function normalizeSlashes(p) {
39
+ return p.replace(/\\/g, "/");
40
+ }
41
+ function isSourceFile(p, extensions) {
42
+ const ext = extname(p).toLowerCase();
43
+ return extensions.includes(ext);
44
+ }
45
+ function isPublicSource(p, extensions) {
46
+ if (!isSourceFile(p, extensions)) return false;
47
+ const norm = normalizeSlashes(p);
48
+ if (norm.includes("/node_modules/") || norm.startsWith("node_modules/")) return false;
49
+ const base = basename(norm).toLowerCase();
50
+ if (/\.(test|spec)\./.test(base)) return false;
51
+ if (base.startsWith("_")) return false;
52
+ return true;
53
+ }
54
+ function isDocFile(p, docNames) {
55
+ const norm = normalizeSlashes(p);
56
+ const base = basename(norm);
57
+ const lowerBase = base.toLowerCase();
58
+ if (docNames.some((name) => lowerBase === name.toLowerCase())) return true;
59
+ if (norm.includes("/docs/") || norm.startsWith("docs/")) return true;
60
+ return false;
61
+ }
62
+ function extractPath(toolInput) {
63
+ const inp = toolInput ?? {};
64
+ const p = inp["path"];
65
+ return typeof p === "string" ? p : void 0;
66
+ }
67
+ function extractDocContent(toolInput) {
68
+ const inp = toolInput ?? {};
69
+ if (typeof inp["content"] === "string") return inp["content"];
70
+ if (typeof inp["new_string"] === "string") return inp["new_string"];
71
+ return void 0;
72
+ }
73
+ function referenceTokens(p) {
74
+ const base = basename(p);
75
+ const withoutExt = base.replace(/\.[^.]+$/, "");
76
+ const tokens = [base];
77
+ if (withoutExt && withoutExt !== base) tokens.push(withoutExt);
78
+ return tokens;
79
+ }
80
+ function isReferenced(path, content) {
81
+ const lowerContent = content.toLowerCase();
82
+ for (const token of referenceTokens(path)) {
83
+ if (token.length < 2) continue;
84
+ if (lowerContent.includes(token.toLowerCase())) return true;
85
+ }
86
+ return false;
87
+ }
88
+ function trackChangedFile(path, maxTracked) {
89
+ state.changedFiles = state.changedFiles.filter((p) => p !== path);
90
+ state.changedFiles.push(path);
91
+ if (state.changedFiles.length > maxTracked) {
92
+ state.changedFiles = state.changedFiles.slice(-maxTracked);
93
+ }
94
+ }
95
+ var plugin = {
96
+ name: "doc-sync-guard",
97
+ version: "0.1.0",
98
+ description: "PostToolUse hook that tracks changed public source files and warns when README/docs edits omit them",
99
+ apiVersion: API_VERSION,
100
+ capabilities: { tools: true, hooks: true },
101
+ defaultConfig: { ...DEFAULTS },
102
+ configSchema: {
103
+ type: "object",
104
+ properties: {
105
+ enabled: { type: "boolean", default: true, description: "Master switch." },
106
+ sourceExtensions: {
107
+ type: "array",
108
+ items: { type: "string" },
109
+ default: [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"],
110
+ description: "Extensions considered public source files."
111
+ },
112
+ docNames: {
113
+ type: "array",
114
+ items: { type: "string" },
115
+ default: ["README.md", "README", "CONTRIBUTING.md", "CHANGELOG.md"],
116
+ description: "Base file names treated as documentation."
117
+ },
118
+ maxTrackedFiles: {
119
+ type: "number",
120
+ minimum: 1,
121
+ maximum: 200,
122
+ default: 20,
123
+ description: "Maximum number of recently changed source files to remember."
124
+ }
125
+ }
126
+ },
127
+ setup(api) {
128
+ state.changedFiles = [];
129
+ state.sourceWrites = 0;
130
+ state.docWrites = 0;
131
+ state.warningsIssued = 0;
132
+ state.hookUnregister = null;
133
+ const cfg = readConfig(api.config.extensions?.["doc-sync-guard"]);
134
+ const hook = (input) => {
135
+ if (!cfg.enabled) return;
136
+ if (input.toolResult?.isError) return;
137
+ const path = extractPath(input.toolInput);
138
+ if (!path || !withinProject(path)) return;
139
+ if (isPublicSource(path, cfg.sourceExtensions)) {
140
+ trackChangedFile(path, cfg.maxTrackedFiles);
141
+ state.sourceWrites += 1;
142
+ return;
143
+ }
144
+ if (isDocFile(path, cfg.docNames)) {
145
+ state.docWrites += 1;
146
+ const content = extractDocContent(input.toolInput);
147
+ if (!content || state.changedFiles.length === 0) return;
148
+ const missing = state.changedFiles.filter((changedPath) => !isReferenced(changedPath, content));
149
+ if (missing.length === 0) return;
150
+ state.warningsIssued += 1;
151
+ const missingList = missing.map((p) => ` - ${p}`).join("\n");
152
+ const message = `
153
+ \u26A0\uFE0F doc-sync-guard: ${path} was updated but does not appear to reference recently changed public source file(s):
154
+ ${missingList}
155
+ Consider mentioning these changes in the documentation.`;
156
+ return { additionalContext: message };
157
+ }
158
+ };
159
+ state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook);
160
+ api.tools.register({
161
+ name: "doc_sync_status",
162
+ description: "Reports doc-sync-guard state: tracked changed files, doc-write count, and warning count.",
163
+ inputSchema: { type: "object", properties: {} },
164
+ permission: "auto",
165
+ category: "Diagnostics",
166
+ mutating: false,
167
+ async execute() {
168
+ return {
169
+ ok: true,
170
+ enabled: cfg.enabled,
171
+ maxTrackedFiles: cfg.maxTrackedFiles,
172
+ changedFiles: [...state.changedFiles],
173
+ counters: {
174
+ sourceWrites: state.sourceWrites,
175
+ docWrites: state.docWrites,
176
+ warningsIssued: state.warningsIssued
177
+ }
178
+ };
179
+ }
180
+ });
181
+ api.log.info("doc-sync-guard plugin loaded", {
182
+ version: "0.1.0",
183
+ maxTrackedFiles: cfg.maxTrackedFiles,
184
+ sourceExtensions: cfg.sourceExtensions,
185
+ docNames: cfg.docNames
186
+ });
187
+ },
188
+ teardown(api) {
189
+ if (state.hookUnregister) {
190
+ try {
191
+ state.hookUnregister();
192
+ } catch {
193
+ }
194
+ state.hookUnregister = null;
195
+ }
196
+ const final = {
197
+ changedFiles: state.changedFiles.length,
198
+ sourceWrites: state.sourceWrites,
199
+ docWrites: state.docWrites,
200
+ warningsIssued: state.warningsIssued
201
+ };
202
+ state.changedFiles = [];
203
+ state.sourceWrites = 0;
204
+ state.docWrites = 0;
205
+ state.warningsIssued = 0;
206
+ api.log.info("doc-sync-guard: teardown complete", { final });
207
+ },
208
+ async health() {
209
+ return {
210
+ ok: true,
211
+ message: `doc-sync-guard: ${state.changedFiles.length} tracked file(s), ${state.warningsIssued} warning(s)`,
212
+ counters: {
213
+ changedFiles: state.changedFiles.length,
214
+ sourceWrites: state.sourceWrites,
215
+ docWrites: state.docWrites,
216
+ warningsIssued: state.warningsIssued
217
+ }
218
+ };
219
+ }
220
+ };
221
+ var doc_sync_guard_default = plugin;
222
+
223
+ export { doc_sync_guard_default as default };
@@ -0,0 +1,33 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * duplicate-code-detector plugin — finds duplicated code blocks across source
5
+ * files using normalized-line fingerprinting.
6
+ *
7
+ * Tools registered:
8
+ * - detect_duplicate_code : Scan a path for duplicated blocks.
9
+ * - duplicate_code_status : Report config + counters.
10
+ *
11
+ * Hooks registered:
12
+ * - PostToolUse with matcher `write|edit` to source files, warning when the
13
+ * changed file introduces blocks that duplicate existing code elsewhere.
14
+ *
15
+ * Config (`config.extensions['duplicate-code-detector']`):
16
+ *
17
+ * ```jsonc
18
+ * {
19
+ * "enabled": true,
20
+ * "minLines": 5,
21
+ * "threshold": 0.8,
22
+ * "extensions": [".ts", ".tsx", ".js", ".jsx"],
23
+ * "excludeDirs": ["node_modules", "dist", ".git", "coverage"],
24
+ * "maxFindings": 20
25
+ * }
26
+ * ```
27
+ *
28
+ * @public
29
+ */
30
+
31
+ declare const plugin: Plugin;
32
+
33
+ export { plugin as default };
@@ -0,0 +1,384 @@
1
+ import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
2
+ import { resolve, isAbsolute, relative, extname } from 'path';
3
+
4
+ // src/duplicate-code-detector/index.ts
5
+ var API_VERSION = "^0.1.10";
6
+ var state = {
7
+ scanCount: 0,
8
+ findingCount: 0,
9
+ hookInvocationCount: 0,
10
+ warningCount: 0,
11
+ errorCount: 0,
12
+ hookUnregister: null,
13
+ lastHookWarning: /* @__PURE__ */ new Map()
14
+ };
15
+ var DEFAULTS = {
16
+ enabled: false,
17
+ minLines: 8,
18
+ threshold: 0.8,
19
+ extensions: [".ts", ".tsx", ".js", ".jsx"],
20
+ excludeDirs: ["node_modules", "dist", ".git", "coverage"],
21
+ maxFindings: 5
22
+ };
23
+ function readConfig(raw) {
24
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
25
+ const r = raw;
26
+ return {
27
+ enabled: r["enabled"] === true,
28
+ minLines: typeof r["minLines"] === "number" && r["minLines"] >= 2 && r["minLines"] <= 100 ? r["minLines"] : DEFAULTS.minLines,
29
+ threshold: typeof r["threshold"] === "number" && r["threshold"] > 0 && r["threshold"] <= 1 ? r["threshold"] : DEFAULTS.threshold,
30
+ extensions: Array.isArray(r["extensions"]) ? r["extensions"].filter((x) => typeof x === "string") : DEFAULTS.extensions,
31
+ excludeDirs: Array.isArray(r["excludeDirs"]) ? r["excludeDirs"].filter((x) => typeof x === "string") : DEFAULTS.excludeDirs,
32
+ maxFindings: typeof r["maxFindings"] === "number" && r["maxFindings"] >= 1 && r["maxFindings"] <= 500 ? r["maxFindings"] : DEFAULTS.maxFindings
33
+ };
34
+ }
35
+ function withinProject(p) {
36
+ if (typeof p !== "string" || p.length === 0 || p.length > 4096) return false;
37
+ const root = process.cwd();
38
+ const resolved = isAbsolute(p) ? resolve(p) : resolve(root, p);
39
+ const rel = relative(root, resolved);
40
+ if (rel === "" || rel === ".") return true;
41
+ if (rel.startsWith("..")) return false;
42
+ if (isAbsolute(rel)) return false;
43
+ return true;
44
+ }
45
+ function matchesExtension(p, exts) {
46
+ const ext = extname(p).toLowerCase();
47
+ return exts.includes(ext);
48
+ }
49
+ function collectSourceFiles(root, cfg) {
50
+ const files = [];
51
+ if (!existsSync(root)) return files;
52
+ const s = statSync(root);
53
+ if (s.isFile()) {
54
+ if (matchesExtension(root, cfg.extensions)) files.push(root);
55
+ return files;
56
+ }
57
+ if (!s.isDirectory()) return files;
58
+ function walk(dir) {
59
+ let entries;
60
+ try {
61
+ entries = readdirSync(dir);
62
+ } catch {
63
+ return;
64
+ }
65
+ for (const entry of entries) {
66
+ const full = resolve(dir, entry);
67
+ let st;
68
+ try {
69
+ st = statSync(full);
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (st.isDirectory()) {
74
+ if (!cfg.excludeDirs.includes(entry)) walk(full);
75
+ } else if (st.isFile() && matchesExtension(full, cfg.extensions)) {
76
+ files.push(full);
77
+ }
78
+ }
79
+ }
80
+ walk(root);
81
+ return files;
82
+ }
83
+ function toPosix(p) {
84
+ return p.replace(/\\/g, "/");
85
+ }
86
+ function relativePath(p) {
87
+ return toPosix(relative(process.cwd(), p));
88
+ }
89
+ function removeInlineComments(line) {
90
+ return line.replace(/\/\/.*$/, "").replace(/\/\*[\s\S]*?\*\//, "");
91
+ }
92
+ function normalizeLine(line) {
93
+ let normalized = line.trim().toLowerCase();
94
+ normalized = removeInlineComments(normalized);
95
+ normalized = normalized.replace(/\s+/g, " ").trim();
96
+ return normalized;
97
+ }
98
+ function buildFingerprint(lines) {
99
+ return lines.map(normalizeLine).filter((l) => l.length > 0).join("\n");
100
+ }
101
+ function extractWindows(filePath, content, minLines) {
102
+ const rawLines = content.split(/\r?\n/);
103
+ const windows = [];
104
+ for (let i = 0; i <= rawLines.length - minLines; i++) {
105
+ const slice = rawLines.slice(i, i + minLines);
106
+ const fingerprint = buildFingerprint(slice);
107
+ if (fingerprint.length === 0) continue;
108
+ const snippet = slice.join("\n");
109
+ windows.push({
110
+ file: filePath,
111
+ startLine: i + 1,
112
+ endLine: i + minLines,
113
+ snippet,
114
+ fingerprint
115
+ });
116
+ }
117
+ return windows;
118
+ }
119
+ function findDuplicates(files, minLines, maxFindings) {
120
+ const byFingerprint = /* @__PURE__ */ new Map();
121
+ for (const [filePath, content] of files.entries()) {
122
+ const windows = extractWindows(filePath, content, minLines);
123
+ for (const w of windows) {
124
+ const list = byFingerprint.get(w.fingerprint) ?? [];
125
+ list.push(w);
126
+ byFingerprint.set(w.fingerprint, list);
127
+ }
128
+ }
129
+ const findings = [];
130
+ for (const [fingerprint, windows] of byFingerprint.entries()) {
131
+ if (windows.length < 2) continue;
132
+ const locations = windows.map((w) => ({
133
+ file: relativePath(w.file),
134
+ startLine: w.startLine,
135
+ endLine: w.endLine,
136
+ snippet: w.snippet
137
+ }));
138
+ findings.push({ fingerprint, lineCount: fingerprint.split("\n").length, locations });
139
+ if (findings.length >= maxFindings) break;
140
+ }
141
+ return findings;
142
+ }
143
+ function scanPath(rawPath, cfg) {
144
+ const root = process.cwd();
145
+ const resolved = isAbsolute(rawPath) ? resolve(rawPath) : resolve(root, rawPath);
146
+ const filePaths = collectSourceFiles(resolved, cfg);
147
+ const files = /* @__PURE__ */ new Map();
148
+ for (const p of filePaths) {
149
+ try {
150
+ files.set(p, readFileSync(p, "utf-8"));
151
+ } catch {
152
+ }
153
+ }
154
+ return { findings: findDuplicates(files, cfg.minLines, cfg.maxFindings), scannedFiles: files.size };
155
+ }
156
+ var plugin = {
157
+ name: "duplicate-code-detector",
158
+ version: "0.1.0",
159
+ description: "Finds duplicated code blocks across source files using normalized-line fingerprinting",
160
+ apiVersion: API_VERSION,
161
+ capabilities: { tools: true, hooks: true },
162
+ defaultConfig: { ...DEFAULTS },
163
+ configSchema: {
164
+ type: "object",
165
+ properties: {
166
+ enabled: { type: "boolean", default: false, description: "Master switch." },
167
+ minLines: {
168
+ type: "number",
169
+ minimum: 2,
170
+ maximum: 100,
171
+ default: 8,
172
+ description: "Minimum number of consecutive lines to form a block."
173
+ },
174
+ threshold: {
175
+ type: "number",
176
+ minimum: 0.01,
177
+ maximum: 1,
178
+ default: 0.8,
179
+ description: "Similarity threshold (currently exact-match only)."
180
+ },
181
+ extensions: {
182
+ type: "array",
183
+ items: { type: "string" },
184
+ default: [".ts", ".tsx", ".js", ".jsx"],
185
+ description: "File extensions to scan."
186
+ },
187
+ excludeDirs: {
188
+ type: "array",
189
+ items: { type: "string" },
190
+ default: ["node_modules", "dist", ".git", "coverage"],
191
+ description: "Directory names to skip while scanning."
192
+ },
193
+ maxFindings: {
194
+ type: "number",
195
+ minimum: 1,
196
+ maximum: 500,
197
+ default: 20,
198
+ description: "Maximum duplicate groups reported per scan."
199
+ }
200
+ }
201
+ },
202
+ setup(api) {
203
+ state.scanCount = 0;
204
+ state.findingCount = 0;
205
+ state.hookInvocationCount = 0;
206
+ state.warningCount = 0;
207
+ state.errorCount = 0;
208
+ state.lastHookWarning.clear();
209
+ if (state.hookUnregister) {
210
+ try {
211
+ state.hookUnregister();
212
+ } catch {
213
+ }
214
+ state.hookUnregister = null;
215
+ }
216
+ const cfg = readConfig(api.config.extensions?.["duplicate-code-detector"]);
217
+ const hook = (input) => {
218
+ if (!cfg.enabled) return;
219
+ if (input.toolResult?.isError) return;
220
+ const inp = input.toolInput ?? {};
221
+ const sourcePath = inp["path"];
222
+ if (!sourcePath || typeof sourcePath !== "string") return;
223
+ if (!withinProject(sourcePath)) return;
224
+ const ext = sourcePath.includes(".") ? sourcePath.slice(sourcePath.lastIndexOf(".")).toLowerCase() : "";
225
+ if (!cfg.extensions.includes(ext)) return;
226
+ state.hookInvocationCount += 1;
227
+ const now = Date.now();
228
+ const lastWarning = state.lastHookWarning.get(sourcePath);
229
+ if (lastWarning !== void 0 && now - lastWarning < 6e4) return;
230
+ const changedFile = resolve(process.cwd(), sourcePath);
231
+ let content;
232
+ try {
233
+ content = readFileSync(changedFile, "utf-8");
234
+ } catch {
235
+ state.errorCount += 1;
236
+ return;
237
+ }
238
+ const changedWindows = extractWindows(changedFile, content, cfg.minLines);
239
+ if (changedWindows.length === 0) return;
240
+ const projectRoot = resolve(process.cwd());
241
+ let otherFiles;
242
+ try {
243
+ const filePaths = collectSourceFiles(projectRoot, cfg).filter((p) => p !== changedFile);
244
+ otherFiles = /* @__PURE__ */ new Map();
245
+ for (const p of filePaths) {
246
+ try {
247
+ otherFiles.set(p, readFileSync(p, "utf-8"));
248
+ } catch {
249
+ }
250
+ }
251
+ } catch {
252
+ state.errorCount += 1;
253
+ return;
254
+ }
255
+ const existingWindows = [];
256
+ for (const [p, c] of otherFiles.entries()) {
257
+ existingWindows.push(...extractWindows(p, c, cfg.minLines));
258
+ }
259
+ const hits = [];
260
+ for (const cw of changedWindows) {
261
+ for (const ew of existingWindows) {
262
+ if (cw.fingerprint === ew.fingerprint) {
263
+ hits.push(ew);
264
+ break;
265
+ }
266
+ }
267
+ }
268
+ if (hits.length === 0) return;
269
+ state.warningCount += hits.length;
270
+ state.lastHookWarning.set(sourcePath, now);
271
+ return {
272
+ additionalContext: `\u26A0\uFE0F duplicate-code-detector: ${sourcePath} contains ${hits.length} block(s) already present elsewhere. Run detect_duplicate_code for details.`,
273
+ contextAs: "separate"
274
+ };
275
+ };
276
+ state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook);
277
+ api.tools.register({
278
+ name: "detect_duplicate_code",
279
+ description: "Scan source files for duplicated code blocks. Uses normalized-line fingerprinting to find identical multi-line blocks across files.",
280
+ inputSchema: {
281
+ type: "object",
282
+ properties: {
283
+ path: { type: "string", default: ".", description: "Directory or file path to scan." }
284
+ }
285
+ },
286
+ permission: "auto",
287
+ category: "Diagnostics",
288
+ mutating: false,
289
+ async execute(input) {
290
+ if (!cfg.enabled) return { ok: false, error: "duplicate-code-detector is disabled" };
291
+ const rawPath = typeof input.path === "string" ? input.path : ".";
292
+ if (!withinProject(rawPath)) {
293
+ return { ok: false, error: "scan path is outside the project root" };
294
+ }
295
+ state.scanCount += 1;
296
+ let result;
297
+ try {
298
+ result = scanPath(rawPath, cfg);
299
+ } catch (err) {
300
+ state.errorCount += 1;
301
+ return { ok: false, error: String(err) };
302
+ }
303
+ state.findingCount += result.findings.length;
304
+ return {
305
+ ok: true,
306
+ path: relativePath(resolve(process.cwd(), rawPath)),
307
+ scannedFiles: result.scannedFiles,
308
+ minLines: cfg.minLines,
309
+ findings: result.findings
310
+ };
311
+ }
312
+ });
313
+ api.tools.register({
314
+ name: "duplicate_code_status",
315
+ description: "Reports duplicate-code-detector state: config + counters.",
316
+ inputSchema: { type: "object", properties: {} },
317
+ permission: "auto",
318
+ category: "Diagnostics",
319
+ mutating: false,
320
+ async execute() {
321
+ return {
322
+ ok: true,
323
+ enabled: cfg.enabled,
324
+ minLines: cfg.minLines,
325
+ threshold: cfg.threshold,
326
+ extensions: cfg.extensions,
327
+ excludeDirs: cfg.excludeDirs,
328
+ maxFindings: cfg.maxFindings,
329
+ counters: {
330
+ scans: state.scanCount,
331
+ findings: state.findingCount,
332
+ hookInvocations: state.hookInvocationCount,
333
+ warnings: state.warningCount,
334
+ errors: state.errorCount
335
+ }
336
+ };
337
+ }
338
+ });
339
+ api.log.info("duplicate-code-detector plugin loaded", {
340
+ version: "0.1.0",
341
+ minLines: cfg.minLines,
342
+ extensions: cfg.extensions
343
+ });
344
+ },
345
+ teardown(api) {
346
+ if (state.hookUnregister) {
347
+ try {
348
+ state.hookUnregister();
349
+ } catch {
350
+ }
351
+ state.hookUnregister = null;
352
+ }
353
+ const final = {
354
+ scans: state.scanCount,
355
+ findings: state.findingCount,
356
+ hookInvocations: state.hookInvocationCount,
357
+ warnings: state.warningCount,
358
+ errors: state.errorCount
359
+ };
360
+ state.scanCount = 0;
361
+ state.findingCount = 0;
362
+ state.hookInvocationCount = 0;
363
+ state.warningCount = 0;
364
+ state.errorCount = 0;
365
+ state.lastHookWarning.clear();
366
+ api.log.info("duplicate-code-detector: teardown complete", { final });
367
+ },
368
+ async health() {
369
+ return {
370
+ ok: state.errorCount === 0,
371
+ message: state.errorCount ? `duplicate-code-detector: ${state.errorCount} error(s)` : `duplicate-code-detector: ${state.scanCount} scan(s), ${state.findingCount} duplicate group(s)`,
372
+ counters: {
373
+ scans: state.scanCount,
374
+ findings: state.findingCount,
375
+ hookInvocations: state.hookInvocationCount,
376
+ warnings: state.warningCount,
377
+ errors: state.errorCount
378
+ }
379
+ };
380
+ }
381
+ };
382
+ var duplicate_code_detector_default = plugin;
383
+
384
+ export { duplicate_code_detector_default as default };
@@ -0,0 +1,38 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * feature-flag-tracker plugin — scans source files for feature-flag-like
5
+ * expressions and reports where they are used.
6
+ *
7
+ * Tools registered:
8
+ * - scan_feature_flags : Scan a path for feature flag usages.
9
+ * - feature_flag_status : Report config + counters.
10
+ *
11
+ * Hooks registered:
12
+ * - PostToolUse with matcher `write|edit` to source files, noting any feature
13
+ * flags used in the changed file.
14
+ *
15
+ * Config (`config.extensions['feature-flag-tracker']`):
16
+ *
17
+ * ```jsonc
18
+ * {
19
+ * "enabled": true,
20
+ * "extensions": [".ts", ".tsx", ".js", ".jsx"],
21
+ * "patterns": ["extra-regex"],
22
+ * "maxFindings": 50
23
+ * }
24
+ * ```
25
+ *
26
+ * @public
27
+ */
28
+
29
+ interface FeatureFlagUsage {
30
+ flag: string;
31
+ file: string;
32
+ line: number;
33
+ context: string;
34
+ pattern: string;
35
+ }
36
+ declare const plugin: Plugin;
37
+
38
+ export { type FeatureFlagUsage, plugin as default };