@wrongstack/plugins 0.305.1 → 0.306.2

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.
@@ -142,6 +142,12 @@ export declare const OFFICIAL_PLUGIN_AUDIT_ENTRIES: readonly [{
142
142
  readonly summary: 'Detects runaway tool-call loops (identical repeats and A-B-A-B oscillation) — warns the model, then blocks';
143
143
  readonly defaultState: 'active';
144
144
  readonly canDisable: true;
145
+ }, {
146
+ readonly name: 'gitignore-guard';
147
+ readonly risk: 'low';
148
+ readonly summary: 'PostToolUse hook that suggests or appends .gitignore entries for build-artifact-looking files after every write or edit';
149
+ readonly defaultState: 'inactive';
150
+ readonly canDisable: true;
145
151
  }, {
146
152
  readonly name: 'path-guard';
147
153
  readonly risk: 'medium';
package/dist/audit.js CHANGED
@@ -168,6 +168,13 @@ var OFFICIAL_PLUGIN_AUDIT_ENTRIES = [
168
168
  defaultState: "active",
169
169
  canDisable: true
170
170
  },
171
+ {
172
+ name: "gitignore-guard",
173
+ risk: "low",
174
+ summary: "PostToolUse hook that suggests or appends .gitignore entries for build-artifact-looking files after every write or edit",
175
+ defaultState: "inactive",
176
+ canDisable: true
177
+ },
171
178
  {
172
179
  name: "path-guard",
173
180
  risk: "medium",
@@ -1,4 +1,4 @@
1
1
  import type { Plugin } from '@wrongstack/core/types';
2
- export declare const OFFICIAL_PLUGIN_SPECIFIERS: readonly ['@wrongstack/plugins/agent-handoff', '@wrongstack/plugins/cost-tracker', '@wrongstack/plugins/file-watcher', '@wrongstack/plugins/git-autocommit', '@wrongstack/plugins/auto-doc', '@wrongstack/plugins/shell-check', '@wrongstack/plugins/cron', '@wrongstack/plugins/template-engine', '@wrongstack/plugins/semver-bump', '@wrongstack/plugins/secret-scanner', '@wrongstack/plugins/todo-tracker', '@wrongstack/plugins/token-budget', '@wrongstack/plugins/lint-gate', '@wrongstack/plugins/branch-guard', '@wrongstack/plugins/diff-summary', '@wrongstack/plugins/commit-validator', '@wrongstack/plugins/format-on-save', '@wrongstack/plugins/test-runner-gate', '@wrongstack/plugins/import-organizer', '@wrongstack/plugins/knowledge-graph', '@wrongstack/plugins/todo-listener', '@wrongstack/plugins/session-recap', '@wrongstack/plugins/spec-linker', '@wrongstack/plugins/loop-breaker', '@wrongstack/plugins/path-guard', '@wrongstack/plugins/process-guard', '@wrongstack/plugins/context-pins', '@wrongstack/plugins/checkpoint', '@wrongstack/plugins/error-lens', '@wrongstack/plugins/dep-guard', '@wrongstack/plugins/config-validator', '@wrongstack/plugins/notify-hub', '@wrongstack/plugins/changelog-writer', '@wrongstack/plugins/injection-shield', '@wrongstack/plugins/llm-cache', '@wrongstack/plugins/model-router', '@wrongstack/plugins/pr-drafter', '@wrongstack/plugins/prompt-firewall', '@wrongstack/plugins/auto-escalate', '@wrongstack/plugins/test-coverage-gate', '@wrongstack/plugins/type-gate', '@wrongstack/plugins/token-throttle', '@wrongstack/plugins/plugin-stack-observer', '@wrongstack/plugins/dependency-vulnerability-gate', '@wrongstack/plugins/migration-planner', '@wrongstack/plugins/semantic-search-indexer', '@wrongstack/plugins/auto-i18n-extractor', '@wrongstack/plugins/doc-sync-guard', '@wrongstack/plugins/api-compatibility-gate', '@wrongstack/plugins/performance-regression-gate', '@wrongstack/plugins/test-flake-detector', '@wrongstack/plugins/schema-evolution-guard', '@wrongstack/plugins/license-audit-gate', '@wrongstack/plugins/accessibility-auditor', '@wrongstack/plugins/security-hotspot-scanner', '@wrongstack/plugins/dead-code-detector', '@wrongstack/plugins/duplicate-code-detector', '@wrongstack/plugins/code-metrics', '@wrongstack/plugins/refactor-suggester', '@wrongstack/plugins/test-generator', '@wrongstack/plugins/release-notes-generator', '@wrongstack/plugins/smart-rename', '@wrongstack/plugins/feature-flag-tracker', '@wrongstack/plugins/interface-contract-guard'];
2
+ export declare const OFFICIAL_PLUGIN_SPECIFIERS: readonly ['@wrongstack/plugins/agent-handoff', '@wrongstack/plugins/cost-tracker', '@wrongstack/plugins/file-watcher', '@wrongstack/plugins/git-autocommit', '@wrongstack/plugins/auto-doc', '@wrongstack/plugins/shell-check', '@wrongstack/plugins/cron', '@wrongstack/plugins/template-engine', '@wrongstack/plugins/semver-bump', '@wrongstack/plugins/secret-scanner', '@wrongstack/plugins/todo-tracker', '@wrongstack/plugins/token-budget', '@wrongstack/plugins/lint-gate', '@wrongstack/plugins/branch-guard', '@wrongstack/plugins/diff-summary', '@wrongstack/plugins/commit-validator', '@wrongstack/plugins/format-on-save', '@wrongstack/plugins/test-runner-gate', '@wrongstack/plugins/import-organizer', '@wrongstack/plugins/knowledge-graph', '@wrongstack/plugins/todo-listener', '@wrongstack/plugins/session-recap', '@wrongstack/plugins/spec-linker', '@wrongstack/plugins/loop-breaker', '@wrongstack/plugins/gitignore-guard', '@wrongstack/plugins/path-guard', '@wrongstack/plugins/process-guard', '@wrongstack/plugins/context-pins', '@wrongstack/plugins/checkpoint', '@wrongstack/plugins/error-lens', '@wrongstack/plugins/dep-guard', '@wrongstack/plugins/config-validator', '@wrongstack/plugins/notify-hub', '@wrongstack/plugins/changelog-writer', '@wrongstack/plugins/injection-shield', '@wrongstack/plugins/llm-cache', '@wrongstack/plugins/model-router', '@wrongstack/plugins/pr-drafter', '@wrongstack/plugins/prompt-firewall', '@wrongstack/plugins/auto-escalate', '@wrongstack/plugins/test-coverage-gate', '@wrongstack/plugins/type-gate', '@wrongstack/plugins/token-throttle', '@wrongstack/plugins/plugin-stack-observer', '@wrongstack/plugins/dependency-vulnerability-gate', '@wrongstack/plugins/migration-planner', '@wrongstack/plugins/semantic-search-indexer', '@wrongstack/plugins/auto-i18n-extractor', '@wrongstack/plugins/doc-sync-guard', '@wrongstack/plugins/api-compatibility-gate', '@wrongstack/plugins/performance-regression-gate', '@wrongstack/plugins/test-flake-detector', '@wrongstack/plugins/schema-evolution-guard', '@wrongstack/plugins/license-audit-gate', '@wrongstack/plugins/accessibility-auditor', '@wrongstack/plugins/security-hotspot-scanner', '@wrongstack/plugins/dead-code-detector', '@wrongstack/plugins/duplicate-code-detector', '@wrongstack/plugins/code-metrics', '@wrongstack/plugins/refactor-suggester', '@wrongstack/plugins/test-generator', '@wrongstack/plugins/release-notes-generator', '@wrongstack/plugins/smart-rename', '@wrongstack/plugins/feature-flag-tracker', '@wrongstack/plugins/interface-contract-guard'];
3
3
  export declare const OFFICIAL_PLUGIN_FACTORIES: readonly (() => Promise<Plugin>)[];
4
4
  //# sourceMappingURL=index.d.ts.map
package/dist/factories.js CHANGED
@@ -24,6 +24,7 @@ var OFFICIAL_PLUGIN_SPECIFIERS = [
24
24
  "@wrongstack/plugins/session-recap",
25
25
  "@wrongstack/plugins/spec-linker",
26
26
  "@wrongstack/plugins/loop-breaker",
27
+ "@wrongstack/plugins/gitignore-guard",
27
28
  "@wrongstack/plugins/path-guard",
28
29
  "@wrongstack/plugins/process-guard",
29
30
  "@wrongstack/plugins/context-pins",
@@ -24,6 +24,7 @@ export { default as featureFlagTrackerPlugin } from './feature-flag-tracker/inde
24
24
  export { default as fileWatcherPlugin } from './file-watcher/index.js';
25
25
  export { default as formatOnSavePlugin } from './format-on-save/index.js';
26
26
  export { default as gitAutocommitPlugin } from './git-autocommit/index.js';
27
+ export { default as gitignoreGuardPlugin } from './gitignore-guard/index.js';
27
28
  export { default as importOrganizerPlugin } from './import-organizer/index.js';
28
29
  export { default as injectionShieldPlugin } from './injection-shield/index.js';
29
30
  export { default as interfaceContractGuardPlugin } from './interface-contract-guard/index.js';
@@ -0,0 +1,82 @@
1
+ /**
2
+ * gitignore-guard plugin — repo hygiene for generated files.
3
+ *
4
+ * PostToolUse hook that detects writes of build-artifact-looking files
5
+ * (`dist/`, `coverage/`, `node_modules/`, `*.env`, …) and either
6
+ * *suggests* a `.gitignore` entry (default) or *appends* it for you.
7
+ *
8
+ * Deliberately orthogonal to path-guard: path-guard BLOCKS writes/edits
9
+ * to protected paths (lockfiles, .env, .git, migrations); gitignore-guard
10
+ * never blocks a tool — it keeps generated files out of the repository by
11
+ * growing the project's `.gitignore`. Both hooks can be active at once.
12
+ *
13
+ * What counts as a build artifact is configurable (`artifactPatterns`),
14
+ * with a curated high-precision default list. Patterns use gitignore-style
15
+ * semantics implemented in `matchGitignorePattern`:
16
+ *
17
+ * - trailing `/` → directory pattern. Bare name (e.g. `dist/`) matches a
18
+ * directory with that name at any depth; an internal slash
19
+ * (e.g. `docs/generated/`) anchors it to the root.
20
+ * - contains `/` → anchored glob matched against the path relative to
21
+ * the project root, e.g. `docs/generated`
22
+ * - otherwise → glob matched against the basename at any depth,
23
+ * e.g. `*.env` matches `.env` and `config/.env`
24
+ *
25
+ * The hook only ever touches `.gitignore` files inside the project root
26
+ * (containment is checked against the hook's `cwd`). In `append` mode it
27
+ * appends missing pattern lines to the first existing `.gitignore` walking
28
+ * from the root down toward the artifact, creating a root `.gitignore` when
29
+ * none exists. It never rewrites or removes existing lines.
30
+ *
31
+ * Tools registered:
32
+ * - gitignore_guard_status : config + per-session counters.
33
+ * - gitignore_guard_append : append an entry for a path (or an explicit
34
+ * pattern) on demand — the completion path for a `suggest`-mode notice.
35
+ *
36
+ * Hooks registered:
37
+ * - PostToolUse, matcher `write|edit`. Skips tool errors, paths outside
38
+ * the project, `.gitignore` writes themselves, paths that already match
39
+ * an existing `.gitignore` line, and paths matched by `ignorePatterns`.
40
+ *
41
+ * Config (`config.extensions['gitignore-guard']`):
42
+ *
43
+ * ```jsonc
44
+ * {
45
+ * "enabled": true, // master switch
46
+ * "mode": "suggest", // "suggest" | "append"
47
+ * "artifactPatterns": [...], // patterns that flag a file as an artifact
48
+ * "ignorePatterns": [], // patterns that never flag (opt-outs)
49
+ * "maxAppendPerCall": 5 // append cap per hook invocation
50
+ * }
51
+ * ```
52
+ *
53
+ * @public
54
+ */
55
+ import type { Plugin } from '@wrongstack/core/types';
56
+ /**
57
+ * Curated default list of build-artifact-looking names. High precision on
58
+ * purpose: this hook fires on every write/edit, so the defaults avoid
59
+ * ambiguous names (e.g. `build/` and `out/` are committed by some repos).
60
+ * Users extend or replace the list via `config.extensions['gitignore-guard']`.
61
+ */
62
+ export declare const DEFAULT_ARTIFACT_PATTERNS: readonly string[];
63
+ /**
64
+ * Gitignore-style match of `pattern` against `relPath` (project-relative,
65
+ * forward slashes). See the module doc for the three pattern shapes.
66
+ */
67
+ export declare function matchGitignorePattern(relPath: string, pattern: string): boolean;
68
+ /** First artifact pattern matching `relPath`, or null when none match. */
69
+ export declare function classifyArtifact(relPath: string, patterns: readonly string[]): string | null;
70
+ export interface GitignoreGuardConfig {
71
+ enabled: boolean;
72
+ mode: 'suggest' | 'append';
73
+ artifactPatterns: readonly string[];
74
+ ignorePatterns: readonly string[];
75
+ maxAppendPerCall: number;
76
+ }
77
+ export declare const DEFAULTS: GitignoreGuardConfig;
78
+ /** Read and validate config; every bad field falls back to its default. */
79
+ export declare function readConfig(raw: unknown): GitignoreGuardConfig;
80
+ declare const plugin: Plugin;
81
+ export default plugin;
82
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export * from './gitignore-guard/index.js';
2
+ export { default } from './gitignore-guard/index.js';
@@ -0,0 +1,442 @@
1
+ // src/gitignore-guard/index.ts
2
+ import { access, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ var API_VERSION = "^0.1.10";
5
+ var DEFAULT_ARTIFACT_PATTERNS = Object.freeze([
6
+ "dist/",
7
+ "coverage/",
8
+ "node_modules/",
9
+ ".next/",
10
+ ".nuxt/",
11
+ ".output/",
12
+ ".cache/",
13
+ ".parcel-cache/",
14
+ ".turbo/",
15
+ ".vite/",
16
+ ".swc/",
17
+ ".pytest_cache/",
18
+ ".mypy_cache/",
19
+ "__pycache__/",
20
+ ".venv/",
21
+ "venv/",
22
+ "target/",
23
+ ".eslintcache",
24
+ "*.tsbuildinfo",
25
+ "*.pyc",
26
+ "*.env",
27
+ "*.env.*"
28
+ ]);
29
+ function toForwardSlashes(p) {
30
+ return sep === "/" ? p : p.split(sep).join("/");
31
+ }
32
+ function globToSource(glob) {
33
+ return glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
34
+ }
35
+ function globToRegExp(glob) {
36
+ return new RegExp(`^${globToSource(glob)}$`);
37
+ }
38
+ function matchGitignorePattern(relPath, pattern) {
39
+ const rel = toForwardSlashes(relPath);
40
+ const pat = pattern.trim();
41
+ if (pat.length === 0 || pat.startsWith("#")) return false;
42
+ const segments = rel.split("/");
43
+ if (pat.endsWith("/")) {
44
+ const dir = pat.slice(0, -1);
45
+ if (dir.length === 0) return false;
46
+ if (dir.includes("/")) {
47
+ return new RegExp(`^${globToSource(dir)}(?:/|$)`).test(rel);
48
+ }
49
+ return segments.some((s) => s === dir);
50
+ }
51
+ if (pat.includes("/")) {
52
+ return globToRegExp(toForwardSlashes(pat)).test(rel);
53
+ }
54
+ const base = segments[segments.length - 1] ?? "";
55
+ return globToRegExp(pat).test(base);
56
+ }
57
+ function classifyArtifact(relPath, patterns) {
58
+ for (const p of patterns) {
59
+ if (matchGitignorePattern(relPath, p)) return p;
60
+ }
61
+ return null;
62
+ }
63
+ async function pathExists(p) {
64
+ try {
65
+ await access(p);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+ async function readGitignoreLines(file) {
72
+ if (!await pathExists(file)) return [];
73
+ try {
74
+ const content = await readFile(file, "utf8");
75
+ return content.split(/\r?\n/);
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+ function isCoveredByLines(lines, relPath) {
81
+ for (const raw of lines) {
82
+ const line = raw.trim();
83
+ if (line.length === 0 || line.startsWith("#")) continue;
84
+ if (matchGitignorePattern(relPath, line)) return true;
85
+ }
86
+ return false;
87
+ }
88
+ function gitignoreCandidates(relDir, root) {
89
+ const parts = relDir === "" ? [] : toForwardSlashes(relDir).split("/");
90
+ const out = [join(root, ".gitignore")];
91
+ let cur = root;
92
+ for (const part of parts) {
93
+ cur = join(cur, part);
94
+ out.push(join(cur, ".gitignore"));
95
+ }
96
+ return out;
97
+ }
98
+ function relDirOf(rel) {
99
+ const idx = rel.lastIndexOf("/");
100
+ return idx < 0 ? "" : rel.slice(0, idx);
101
+ }
102
+ async function firstExistingGitignore(candidates) {
103
+ for (const c of candidates) {
104
+ if (await pathExists(c)) return c;
105
+ }
106
+ return null;
107
+ }
108
+ async function appendPatterns(target, patterns, limit) {
109
+ const existing = await readGitignoreLines(target);
110
+ const seen = new Set(
111
+ existing.map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"))
112
+ );
113
+ const missing = patterns.filter((p) => !seen.has(p)).slice(0, limit);
114
+ if (missing.length === 0) return { appended: [] };
115
+ const current = await pathExists(target) ? await readFile(target, "utf8") : "";
116
+ const base = current.length === 0 ? "" : current.endsWith("\n") ? current : `${current}
117
+ `;
118
+ const next = `${base}${missing.map((p) => `${p}
119
+ `).join("")}`;
120
+ await writeFile(target, next, "utf8");
121
+ return { appended: missing };
122
+ }
123
+ function projectRelativePath(rawPath, cwd) {
124
+ const root = cwd ?? process.cwd();
125
+ const abs = isAbsolute(rawPath) ? rawPath : resolve(root, rawPath);
126
+ const rel = toForwardSlashes(relative(root, abs));
127
+ if (rel.startsWith("..") || isAbsolute(rel) || rel === "") return null;
128
+ return { abs, rel, root };
129
+ }
130
+ var DEFAULTS = {
131
+ enabled: true,
132
+ mode: "suggest",
133
+ artifactPatterns: DEFAULT_ARTIFACT_PATTERNS,
134
+ ignorePatterns: [],
135
+ maxAppendPerCall: 5
136
+ };
137
+ function normalizePatterns(value) {
138
+ if (!Array.isArray(value)) return [];
139
+ const out = [];
140
+ for (const v of value) {
141
+ if (typeof v !== "string") continue;
142
+ const t = v.trim();
143
+ if (t.length === 0 || t.startsWith("#")) continue;
144
+ out.push(t);
145
+ }
146
+ return out;
147
+ }
148
+ function readConfig(raw) {
149
+ if (!raw || typeof raw !== "object") {
150
+ return { ...DEFAULTS, artifactPatterns: [...DEFAULTS.artifactPatterns] };
151
+ }
152
+ const r = raw;
153
+ const artifact = normalizePatterns(r["artifactPatterns"]);
154
+ return {
155
+ enabled: r["enabled"] !== false,
156
+ mode: r["mode"] === "append" ? "append" : "suggest",
157
+ artifactPatterns: artifact.length > 0 ? artifact : [...DEFAULTS.artifactPatterns],
158
+ ignorePatterns: normalizePatterns(r["ignorePatterns"]),
159
+ maxAppendPerCall: typeof r["maxAppendPerCall"] === "number" && Number.isInteger(r["maxAppendPerCall"]) && r["maxAppendPerCall"] > 0 ? Math.min(r["maxAppendPerCall"], 50) : DEFAULTS.maxAppendPerCall
160
+ };
161
+ }
162
+ var state = {
163
+ invocationCount: 0,
164
+ /** Artifacts detected after ignore/coverage checks passed. */
165
+ matchedCount: 0,
166
+ /** Suggest-mode notices injected into the tool result. */
167
+ suggestCount: 0,
168
+ /** Lines appended to a .gitignore (hook or append tool). */
169
+ appendCount: 0,
170
+ /** Paths skipped because an existing .gitignore line already covers them. */
171
+ coveredSkipCount: 0,
172
+ errorCount: 0,
173
+ /** Hook handle for teardown. */
174
+ hookUnregister: null,
175
+ /** Last detection — surfaced by health() + status tool. */
176
+ lastResult: null
177
+ };
178
+ function clearRegistrations() {
179
+ if (state.hookUnregister) {
180
+ try {
181
+ state.hookUnregister();
182
+ } catch {
183
+ }
184
+ state.hookUnregister = null;
185
+ }
186
+ }
187
+ var plugin = {
188
+ name: "gitignore-guard",
189
+ version: "0.1.0",
190
+ description: "PostToolUse hook that suggests or appends .gitignore entries for build-artifact-looking files after every write or edit",
191
+ apiVersion: API_VERSION,
192
+ capabilities: { hooks: true, tools: true },
193
+ configFields: {
194
+ enabled: { lifecycle: "hot" },
195
+ mode: { lifecycle: "hot" },
196
+ artifactPatterns: { lifecycle: "hot" },
197
+ ignorePatterns: { lifecycle: "hot" },
198
+ maxAppendPerCall: { lifecycle: "hot" }
199
+ },
200
+ defaultConfig: {
201
+ enabled: DEFAULTS.enabled,
202
+ mode: DEFAULTS.mode,
203
+ artifactPatterns: [...DEFAULTS.artifactPatterns],
204
+ ignorePatterns: [],
205
+ maxAppendPerCall: DEFAULTS.maxAppendPerCall
206
+ },
207
+ configSchema: {
208
+ type: "object",
209
+ properties: {
210
+ enabled: {
211
+ type: "boolean",
212
+ default: true,
213
+ description: "Master switch. When false, the hook is a no-op."
214
+ },
215
+ mode: {
216
+ type: "string",
217
+ enum: ["suggest", "append"],
218
+ default: "suggest",
219
+ description: '"suggest" injects a notice into the tool result; "append" writes the missing pattern to a .gitignore automatically.'
220
+ },
221
+ artifactPatterns: {
222
+ type: "array",
223
+ items: { type: "string" },
224
+ default: [...DEFAULTS.artifactPatterns],
225
+ description: "Gitignore-style patterns that flag a written file as a build artifact. Empty array falls back to the curated defaults."
226
+ },
227
+ ignorePatterns: {
228
+ type: "array",
229
+ items: { type: "string" },
230
+ default: [],
231
+ description: "Gitignore-style patterns that never flag a file, even when artifactPatterns matches. Use for intentionally-committed generated files."
232
+ },
233
+ maxAppendPerCall: {
234
+ type: "integer",
235
+ minimum: 1,
236
+ maximum: 50,
237
+ default: 5,
238
+ description: "Maximum number of lines appended per hook invocation."
239
+ }
240
+ }
241
+ },
242
+ async setup(api) {
243
+ clearRegistrations();
244
+ state.invocationCount = 0;
245
+ state.matchedCount = 0;
246
+ state.suggestCount = 0;
247
+ state.appendCount = 0;
248
+ state.coveredSkipCount = 0;
249
+ state.errorCount = 0;
250
+ state.lastResult = null;
251
+ const hook = async (input) => {
252
+ const cfg = readConfig(api.config.extensions?.["gitignore-guard"]);
253
+ if (!cfg.enabled) return;
254
+ if (input.toolResult?.isError) return;
255
+ const toolName = input.toolName ?? "";
256
+ const inp = input.toolInput ?? {};
257
+ const rawPath = inp["path"];
258
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0) return;
259
+ if (basename(rawPath) === ".gitignore") return;
260
+ const resolved = projectRelativePath(rawPath, input.cwd);
261
+ if (!resolved) return;
262
+ state.invocationCount += 1;
263
+ const matched = classifyArtifact(resolved.rel, cfg.artifactPatterns);
264
+ if (!matched) return;
265
+ if (classifyArtifact(resolved.rel, cfg.ignorePatterns)) return;
266
+ state.matchedCount += 1;
267
+ const candidates = gitignoreCandidates(relDirOf(resolved.rel), resolved.root);
268
+ for (const candidate of candidates) {
269
+ const lines = await readGitignoreLines(candidate);
270
+ if (isCoveredByLines(lines, resolved.rel)) {
271
+ state.coveredSkipCount += 1;
272
+ return;
273
+ }
274
+ }
275
+ state.lastResult = {
276
+ path: resolved.rel,
277
+ tool: toolName,
278
+ pattern: matched,
279
+ mode: cfg.mode,
280
+ when: (/* @__PURE__ */ new Date()).toISOString()
281
+ };
282
+ if (cfg.mode === "append") {
283
+ const nearest = await firstExistingGitignore(candidates);
284
+ const target = nearest ?? join(resolved.root, ".gitignore");
285
+ const { appended } = await appendPatterns(target, [matched], cfg.maxAppendPerCall);
286
+ if (appended.length === 0) {
287
+ state.coveredSkipCount += 1;
288
+ return;
289
+ }
290
+ state.appendCount += 1;
291
+ api.metrics.counter("append");
292
+ api.log.info(`gitignore-guard: appended '${matched}' to ${target}`, {
293
+ path: resolved.rel,
294
+ tool: toolName
295
+ });
296
+ return {
297
+ additionalContext: `
298
+ \u{1F4E6} gitignore-guard: '${resolved.rel}' looks like a build artifact (pattern '${matched}'). Appended '${matched}' to ${target}.`
299
+ };
300
+ }
301
+ state.suggestCount += 1;
302
+ api.metrics.counter("suggest");
303
+ api.log.info(`gitignore-guard: suggested '${matched}' for ${resolved.rel}`, {
304
+ path: resolved.rel,
305
+ tool: toolName
306
+ });
307
+ return {
308
+ additionalContext: `
309
+ \u{1F4E6} gitignore-guard: '${resolved.rel}' looks like a build artifact (pattern '${matched}'). Add '${matched}' to .gitignore \u2014 call the gitignore_guard_append tool with path: "${resolved.rel}" to append it now, or set config mode to "append" to do this automatically.`
310
+ };
311
+ };
312
+ state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook, {
313
+ name: "gitignore-guard"
314
+ });
315
+ api.tools.register({
316
+ name: "gitignore_guard_status",
317
+ description: "Reports gitignore-guard state: mode, active pattern lists, and per-session suggest/append/skip counters.",
318
+ inputSchema: { type: "object", properties: {} },
319
+ permission: "auto",
320
+ category: "Code Quality",
321
+ mutating: false,
322
+ async execute() {
323
+ const cfg = readConfig(api.config.extensions?.["gitignore-guard"]);
324
+ return {
325
+ ok: true,
326
+ enabled: cfg.enabled,
327
+ mode: cfg.mode,
328
+ artifactPatterns: cfg.artifactPatterns,
329
+ ignorePatterns: cfg.ignorePatterns,
330
+ maxAppendPerCall: cfg.maxAppendPerCall,
331
+ counters: {
332
+ invocations: state.invocationCount,
333
+ matched: state.matchedCount,
334
+ suggests: state.suggestCount,
335
+ appends: state.appendCount,
336
+ coveredSkips: state.coveredSkipCount,
337
+ errors: state.errorCount
338
+ },
339
+ lastResult: state.lastResult
340
+ };
341
+ }
342
+ });
343
+ api.tools.register({
344
+ name: "gitignore_guard_append",
345
+ description: "Appends a .gitignore entry for a build-artifact-looking path (or an explicit pattern). Use after gitignore-guard suggests an entry, or to ignore a generated file proactively.",
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {
349
+ path: {
350
+ type: "string",
351
+ description: "Path of the artifact file or directory, relative to the project root or absolute."
352
+ },
353
+ pattern: {
354
+ type: "string",
355
+ description: "Optional explicit gitignore pattern to append. When omitted, the pattern is derived from the path via artifactPatterns."
356
+ }
357
+ },
358
+ required: ["path"]
359
+ },
360
+ permission: "auto",
361
+ category: "Code Quality",
362
+ mutating: true,
363
+ async execute(input) {
364
+ const rawPath = typeof input?.path === "string" ? input.path : "";
365
+ if (rawPath.length === 0) return { ok: false, reason: "path is required" };
366
+ const resolved = projectRelativePath(rawPath, process.cwd());
367
+ if (!resolved) {
368
+ return { ok: false, reason: `path outside project root: ${rawPath}` };
369
+ }
370
+ const cfg = readConfig(api.config.extensions?.["gitignore-guard"]);
371
+ const explicit = typeof input?.pattern === "string" && input.pattern.trim().length > 0 ? input.pattern.trim() : null;
372
+ const pattern = explicit ?? classifyArtifact(resolved.rel, cfg.artifactPatterns);
373
+ if (!pattern) {
374
+ return {
375
+ ok: false,
376
+ reason: `'${resolved.rel}' does not match any configured artifact pattern; pass an explicit pattern`
377
+ };
378
+ }
379
+ const candidates = gitignoreCandidates(relDirOf(resolved.rel), resolved.root);
380
+ const nearest = await firstExistingGitignore(candidates);
381
+ const target = nearest ?? join(resolved.root, ".gitignore");
382
+ const { appended } = await appendPatterns(target, [pattern], cfg.maxAppendPerCall);
383
+ if (appended.length === 0) {
384
+ state.coveredSkipCount += 1;
385
+ return { ok: true, alreadyCovered: true, gitignore: target, appended: [] };
386
+ }
387
+ state.appendCount += 1;
388
+ api.log.info(`gitignore-guard: manual append '${pattern}' to ${target}`, {
389
+ path: resolved.rel
390
+ });
391
+ return { ok: true, alreadyCovered: false, gitignore: target, appended };
392
+ }
393
+ });
394
+ api.log.info("gitignore-guard plugin loaded", {
395
+ version: "0.1.0",
396
+ mode: readConfig(api.config.extensions?.["gitignore-guard"]).mode
397
+ });
398
+ },
399
+ teardown(api) {
400
+ clearRegistrations();
401
+ const final = {
402
+ invocations: state.invocationCount,
403
+ matched: state.matchedCount,
404
+ suggests: state.suggestCount,
405
+ appends: state.appendCount,
406
+ coveredSkips: state.coveredSkipCount,
407
+ errors: state.errorCount
408
+ };
409
+ state.invocationCount = 0;
410
+ state.matchedCount = 0;
411
+ state.suggestCount = 0;
412
+ state.appendCount = 0;
413
+ state.coveredSkipCount = 0;
414
+ state.errorCount = 0;
415
+ state.lastResult = null;
416
+ api.log.info("gitignore-guard: teardown complete", { final });
417
+ },
418
+ async health() {
419
+ return {
420
+ ok: true,
421
+ message: state.lastResult === null ? `gitignore-guard: ${state.invocationCount} invocation(s), ${state.suggestCount} suggested, ${state.appendCount} appended, ${state.coveredSkipCount} already-covered` : `gitignore-guard: last '${state.lastResult.pattern}' for ${state.lastResult.path} (${state.lastResult.tool}, ${state.lastResult.mode} mode) at ${state.lastResult.when}`,
422
+ counters: {
423
+ invocations: state.invocationCount,
424
+ matched: state.matchedCount,
425
+ suggests: state.suggestCount,
426
+ appends: state.appendCount,
427
+ coveredSkips: state.coveredSkipCount,
428
+ errors: state.errorCount
429
+ },
430
+ lastResult: state.lastResult
431
+ };
432
+ }
433
+ };
434
+ var gitignore_guard_default = plugin;
435
+ export {
436
+ DEFAULTS,
437
+ DEFAULT_ARTIFACT_PATTERNS,
438
+ classifyArtifact,
439
+ gitignore_guard_default as default,
440
+ matchGitignorePattern,
441
+ readConfig
442
+ };