kibi-cli 0.8.0 → 0.10.1

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 (70) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +11 -0
  3. package/dist/commands/check.d.ts.map +1 -1
  4. package/dist/commands/check.js +229 -4
  5. package/dist/commands/init-helpers.d.ts.map +1 -1
  6. package/dist/commands/init-helpers.js +11 -14
  7. package/dist/commands/migrate.d.ts +9 -0
  8. package/dist/commands/migrate.d.ts.map +1 -0
  9. package/dist/commands/migrate.js +183 -0
  10. package/dist/commands/sync/manifest.d.ts +8 -2
  11. package/dist/commands/sync/manifest.d.ts.map +1 -1
  12. package/dist/commands/sync/manifest.js +56 -11
  13. package/dist/commands/sync.d.ts +1 -0
  14. package/dist/commands/sync.d.ts.map +1 -1
  15. package/dist/commands/sync.js +9 -7
  16. package/dist/extractors/manifest.d.ts +30 -0
  17. package/dist/extractors/manifest.d.ts.map +1 -1
  18. package/dist/extractors/manifest.js +60 -7
  19. package/dist/extractors/symbol-coordinates.d.ts +15 -0
  20. package/dist/extractors/symbol-coordinates.d.ts.map +1 -0
  21. package/dist/extractors/symbol-coordinates.js +83 -0
  22. package/dist/public/check-types.d.ts +3 -1
  23. package/dist/public/check-types.d.ts.map +1 -1
  24. package/dist/public/check-types.js +1 -0
  25. package/dist/public/extractors/manifest.d.ts +1 -1
  26. package/dist/public/extractors/manifest.d.ts.map +1 -1
  27. package/dist/public/extractors/manifest.js +1 -1
  28. package/dist/public/ignore-policy.d.ts +10 -0
  29. package/dist/public/ignore-policy.d.ts.map +1 -0
  30. package/dist/public/ignore-policy.js +219 -0
  31. package/dist/public/schema-version.d.ts +3 -0
  32. package/dist/public/schema-version.d.ts.map +1 -0
  33. package/dist/public/schema-version.js +1 -0
  34. package/dist/traceability/evidence-model.d.ts +142 -0
  35. package/dist/traceability/evidence-model.d.ts.map +1 -0
  36. package/dist/traceability/evidence-model.js +70 -0
  37. package/dist/traceability/git-staged.d.ts +1 -0
  38. package/dist/traceability/git-staged.d.ts.map +1 -1
  39. package/dist/traceability/git-staged.js +28 -3
  40. package/dist/traceability/staged-diagnostics.d.ts +25 -0
  41. package/dist/traceability/staged-diagnostics.d.ts.map +1 -0
  42. package/dist/traceability/staged-diagnostics.js +67 -0
  43. package/dist/traceability/staged-impact-contract.d.ts +57 -0
  44. package/dist/traceability/staged-impact-contract.d.ts.map +1 -0
  45. package/dist/traceability/staged-impact-contract.js +202 -0
  46. package/dist/traceability/staged-symbols-manifest.d.ts +23 -0
  47. package/dist/traceability/staged-symbols-manifest.d.ts.map +1 -0
  48. package/dist/traceability/staged-symbols-manifest.js +269 -0
  49. package/dist/traceability/symbol-extract.d.ts.map +1 -1
  50. package/dist/traceability/symbol-extract.js +33 -9
  51. package/dist/utils/config.d.ts +1 -0
  52. package/dist/utils/config.d.ts.map +1 -1
  53. package/dist/utils/config.js +35 -22
  54. package/dist/utils/manifest-paths.d.ts +8 -0
  55. package/dist/utils/manifest-paths.d.ts.map +1 -0
  56. package/dist/utils/manifest-paths.js +62 -0
  57. package/dist/utils/rule-registry.d.ts.map +1 -1
  58. package/dist/utils/rule-registry.js +6 -0
  59. package/dist/utils/schema-version.d.ts +14 -0
  60. package/dist/utils/schema-version.d.ts.map +1 -0
  61. package/dist/utils/schema-version.js +59 -0
  62. package/dist/utils/strict-modeling.d.ts +64 -0
  63. package/dist/utils/strict-modeling.d.ts.map +1 -0
  64. package/dist/utils/strict-modeling.js +371 -0
  65. package/package.json +13 -3
  66. package/schema/config.json +8 -1
  67. package/src/public/check-types.ts +15 -1
  68. package/src/public/extractors/manifest.ts +2 -0
  69. package/src/public/ignore-policy.ts +229 -0
  70. package/src/public/schema-version.ts +6 -0
@@ -0,0 +1,371 @@
1
+ /*
2
+ * Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ * Copyright (C) 2026 Piotr Franczyk
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License as published by
7
+ * the Free Software Foundation, either version 3 of the License, or
8
+ * (at your option) any later version.
9
+ *
10
+ * This program is distributed in the hope that it will be useful,
11
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ * GNU Affero General Public License for more details.
14
+ *
15
+ * You should have received a copy of the GNU Affero General Public License
16
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import * as path from "node:path";
20
+ import { DEFAULT_CONFIG } from "./config.js";
21
+ const STRICT_CONFIDENCE_THRESHOLD = 0.7;
22
+ export function normalizeSubjectKey(value) {
23
+ const normalized = value
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[\\/]+/g, ".")
27
+ .replace(/[^a-z0-9.]+/g, "_")
28
+ .replace(/_+/g, "_")
29
+ .replace(/\.+/g, ".")
30
+ .replace(/(^[._]+|[._]+$)/g, "");
31
+ if (!normalized) {
32
+ throw new Error("Semantic claim subjectKey must normalize to a non-empty value");
33
+ }
34
+ return normalized;
35
+ }
36
+ export function normalizePropertyKey(value) {
37
+ const normalized = value
38
+ .trim()
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, "_")
41
+ .replace(/_+/g, "_")
42
+ .replace(/^_+|_+$/g, "");
43
+ if (!normalized) {
44
+ throw new Error("Semantic claim propertyKey must normalize to a non-empty value");
45
+ }
46
+ return normalized;
47
+ }
48
+ export function buildStableRequirementIds(claim) {
49
+ const normalizedSource = normalizeSourceKey(claim.source);
50
+ const normalizedSubjectKey = normalizeSubjectKey(claim.subjectKey);
51
+ const normalizedPropertyKey = normalizePropertyKey(claim.propertyKey);
52
+ const normalizedValue = normalizeStableValue(claim.value);
53
+ const stableKey = [
54
+ normalizedSource,
55
+ normalizedSubjectKey,
56
+ normalizedPropertyKey,
57
+ claim.operator,
58
+ normalizedValue,
59
+ ].join(":");
60
+ return {
61
+ stableKey,
62
+ reqId: buildEntityId("REQ-AUTO", `req:${stableKey}`),
63
+ subjectFactId: buildEntityId("FACT-SUBJECT", `subject:${stableKey}`),
64
+ propertyFactId: buildEntityId("FACT-PROP", `property:${stableKey}`),
65
+ observationFactId: buildEntityId("FACT-OBS", `observation:${stableKey}`),
66
+ normalizedSource,
67
+ normalizedSubjectKey,
68
+ normalizedPropertyKey,
69
+ normalizedValue,
70
+ };
71
+ }
72
+ export function buildStrictWriteSet(input) {
73
+ const statement = normalizeStatement(input.statement);
74
+ const confidence = normalizeConfidence(input.claim.confidence);
75
+ const ids = buildStableRequirementIds(input.claim);
76
+ const textRef = normalizeTextRef(input.claim.provenance, input.claim.source);
77
+ const metadataTags = buildMetadataTags({
78
+ confidence,
79
+ provenance: textRef,
80
+ });
81
+ const sourcePaths = resolveEntitySourcePaths(input.config?.paths);
82
+ if (confidence < STRICT_CONFIDENCE_THRESHOLD) {
83
+ return {
84
+ observationFact: {
85
+ type: "fact",
86
+ id: ids.observationFactId,
87
+ properties: {
88
+ id: ids.observationFactId,
89
+ title: statement,
90
+ status: "active",
91
+ source: buildEntitySourcePath(sourcePaths.facts, ids.observationFactId),
92
+ text_ref: textRef,
93
+ tags: buildUniqueTags([...metadataTags, "lane:observation", "review:required"]),
94
+ fact_kind: "observation",
95
+ subject_key: ids.normalizedSubjectKey,
96
+ property_key: ids.normalizedPropertyKey,
97
+ canonical_key: ids.stableKey,
98
+ },
99
+ },
100
+ relationships: [],
101
+ isStrict: false,
102
+ confidence,
103
+ };
104
+ }
105
+ const req = {
106
+ type: "req",
107
+ id: ids.reqId,
108
+ properties: {
109
+ id: ids.reqId,
110
+ title: statement,
111
+ status: "open",
112
+ source: buildEntitySourcePath(sourcePaths.requirements, ids.reqId),
113
+ text_ref: textRef,
114
+ tags: buildUniqueTags([...metadataTags, "lane:strict"]),
115
+ },
116
+ };
117
+ const subjectFact = {
118
+ type: "fact",
119
+ id: ids.subjectFactId,
120
+ properties: {
121
+ id: ids.subjectFactId,
122
+ title: humanizeKey(ids.normalizedSubjectKey),
123
+ status: "active",
124
+ source: buildEntitySourcePath(sourcePaths.facts, ids.subjectFactId),
125
+ text_ref: textRef,
126
+ tags: buildUniqueTags([...metadataTags, "lane:strict", "fact:subject"]),
127
+ fact_kind: "subject",
128
+ subject_key: ids.normalizedSubjectKey,
129
+ canonical_key: ids.normalizedSubjectKey,
130
+ },
131
+ };
132
+ const propertyFact = {
133
+ type: "fact",
134
+ id: ids.propertyFactId,
135
+ properties: {
136
+ id: ids.propertyFactId,
137
+ title: buildPropertyFactTitle(ids.normalizedPropertyKey, input.claim),
138
+ status: "active",
139
+ source: buildEntitySourcePath(sourcePaths.facts, ids.propertyFactId),
140
+ text_ref: textRef,
141
+ tags: buildUniqueTags([...metadataTags, "lane:strict", "fact:property_value"]),
142
+ fact_kind: "property_value",
143
+ subject_key: ids.normalizedSubjectKey,
144
+ property_key: ids.normalizedPropertyKey,
145
+ canonical_key: ids.stableKey,
146
+ ...buildPropertyFactFields(input.claim),
147
+ },
148
+ };
149
+ const relationships = dedupeRelationships([
150
+ {
151
+ type: "constrains",
152
+ from: req.id,
153
+ to: subjectFact.id,
154
+ source: input.claim.source,
155
+ confidence,
156
+ },
157
+ {
158
+ type: "requires_property",
159
+ from: req.id,
160
+ to: propertyFact.id,
161
+ source: input.claim.source,
162
+ confidence,
163
+ },
164
+ ]);
165
+ return {
166
+ req,
167
+ subjectFact,
168
+ propertyFact,
169
+ relationships,
170
+ isStrict: true,
171
+ confidence,
172
+ };
173
+ }
174
+ export function modelRequirementClaims(inputs) {
175
+ const seen = new Set();
176
+ const modeled = [];
177
+ for (const input of inputs) {
178
+ const writeSet = buildStrictWriteSet(input);
179
+ const stableId = writeSet.isStrict ? writeSet.req.id : writeSet.observationFact.id;
180
+ if (seen.has(stableId)) {
181
+ continue;
182
+ }
183
+ seen.add(stableId);
184
+ modeled.push(writeSet);
185
+ }
186
+ return modeled;
187
+ }
188
+ function buildEntityId(prefix, value) {
189
+ const hash = createHash("sha256");
190
+ hash.update(value);
191
+ return `${prefix}-${hash.digest("hex").substring(0, 16).toUpperCase()}`;
192
+ }
193
+ function normalizeSourceKey(value) {
194
+ const normalized = value
195
+ .trim()
196
+ .toLowerCase()
197
+ .replace(/[^a-z0-9]+/g, "-")
198
+ .replace(/-+/g, "-")
199
+ .replace(/(^-|-$)/g, "");
200
+ if (!normalized) {
201
+ throw new Error("Semantic claim source must normalize to a non-empty value");
202
+ }
203
+ return normalized;
204
+ }
205
+ function normalizeStableValue(value) {
206
+ if (typeof value === "string") {
207
+ const normalized = value
208
+ .trim()
209
+ .toLowerCase()
210
+ .replace(/\s+/g, "_")
211
+ .replace(/[^a-z0-9._-]+/g, "_")
212
+ .replace(/_+/g, "_")
213
+ .replace(/^_+|_+$/g, "");
214
+ return normalized || "empty";
215
+ }
216
+ return String(value);
217
+ }
218
+ function normalizeStatement(statement) {
219
+ const normalized = statement.trim();
220
+ if (!normalized) {
221
+ throw new Error("Strict modeling requires a non-empty prose statement");
222
+ }
223
+ return normalized;
224
+ }
225
+ function normalizeTextRef(provenance, source) {
226
+ const value = provenance?.trim() || source.trim();
227
+ if (!value) {
228
+ throw new Error("Strict modeling requires claim provenance or source");
229
+ }
230
+ return value;
231
+ }
232
+ function normalizeConfidence(value) {
233
+ if (!Number.isFinite(value)) {
234
+ return 0;
235
+ }
236
+ return Math.round(Math.min(1, Math.max(0, value)) * 100) / 100;
237
+ }
238
+ function toConfidenceBand(confidence) {
239
+ if (confidence >= 0.9)
240
+ return "high";
241
+ if (confidence >= 0.8)
242
+ return "medium";
243
+ return "low";
244
+ }
245
+ function buildMetadataTags({ confidence, provenance, }) {
246
+ return buildUniqueTags([
247
+ "strict-modeling",
248
+ `confidence:${confidence.toFixed(2)}`,
249
+ `confidence-band:${toConfidenceBand(confidence)}`,
250
+ `provenance:${normalizeSourceKey(provenance)}`,
251
+ ]);
252
+ }
253
+ function buildUniqueTags(tags) {
254
+ return Array.from(new Set(tags));
255
+ }
256
+ function buildPropertyFactTitle(normalizedPropertyKey, claim) {
257
+ const label = humanizeKey(normalizedPropertyKey);
258
+ if (claim.operator === "polarity") {
259
+ return `${label} ${normalizePolarityValue(claim.value)}`;
260
+ }
261
+ const operatorLabel = claim.operator === "bool"
262
+ ? "="
263
+ : claim.operator === "eq"
264
+ ? "="
265
+ : claim.operator === "neq"
266
+ ? "!="
267
+ : claim.operator === "gte"
268
+ ? ">="
269
+ : "<=";
270
+ return `${label} ${operatorLabel} ${String(claim.value)}`;
271
+ }
272
+ function buildPropertyFactFields(claim) {
273
+ if (claim.operator === "polarity") {
274
+ return {
275
+ polarity: normalizePolarityValue(claim.value),
276
+ };
277
+ }
278
+ if (claim.operator === "bool") {
279
+ return {
280
+ operator: "eq",
281
+ value_type: "bool",
282
+ value_bool: normalizeBooleanValue(claim.value),
283
+ };
284
+ }
285
+ return {
286
+ operator: claim.operator,
287
+ ...buildTypedValueFields(claim.value),
288
+ };
289
+ }
290
+ function buildTypedValueFields(value) {
291
+ if (typeof value === "boolean") {
292
+ return {
293
+ value_type: "bool",
294
+ value_bool: value,
295
+ };
296
+ }
297
+ if (typeof value === "number") {
298
+ if (Number.isInteger(value)) {
299
+ return {
300
+ value_type: "int",
301
+ value_int: value,
302
+ };
303
+ }
304
+ return {
305
+ value_type: "number",
306
+ value_number: value,
307
+ };
308
+ }
309
+ return {
310
+ value_type: "string",
311
+ value_string: value,
312
+ };
313
+ }
314
+ function normalizePolarityValue(value) {
315
+ if (typeof value === "boolean") {
316
+ return value ? "require" : "forbid";
317
+ }
318
+ if (typeof value === "string") {
319
+ const normalized = value.trim().toLowerCase();
320
+ if (normalized === "require" || normalized === "forbid") {
321
+ return normalized;
322
+ }
323
+ }
324
+ throw new Error("Polarity claims must use a boolean or the string 'require'/'forbid'");
325
+ }
326
+ function normalizeBooleanValue(value) {
327
+ if (typeof value === "boolean") {
328
+ return value;
329
+ }
330
+ if (typeof value === "string") {
331
+ const normalized = value.trim().toLowerCase();
332
+ if (normalized === "true")
333
+ return true;
334
+ if (normalized === "false")
335
+ return false;
336
+ }
337
+ throw new Error("Boolean claims must use a boolean or the string 'true'/'false'");
338
+ }
339
+ function humanizeKey(value) {
340
+ return value
341
+ .split(/[._-]+/)
342
+ .filter(Boolean)
343
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
344
+ .join(" ");
345
+ }
346
+ function dedupeRelationships(relationships) {
347
+ const seen = new Set();
348
+ const deduped = [];
349
+ for (const relationship of relationships) {
350
+ const key = `${relationship.type}:${relationship.from}:${relationship.to}`;
351
+ if (seen.has(key)) {
352
+ continue;
353
+ }
354
+ seen.add(key);
355
+ deduped.push(relationship);
356
+ }
357
+ return deduped;
358
+ }
359
+ function resolveEntitySourcePaths(configPaths) {
360
+ return {
361
+ requirements: normalizeEntityDirectory(configPaths?.requirements, DEFAULT_CONFIG.paths.requirements),
362
+ facts: normalizeEntityDirectory(configPaths?.facts, DEFAULT_CONFIG.paths.facts),
363
+ };
364
+ }
365
+ function normalizeEntityDirectory(directory, fallback) {
366
+ const selected = directory?.trim() || fallback?.trim() || "documentation";
367
+ return selected.split(path.sep).join("/").replace(/\/+$/g, "");
368
+ }
369
+ function buildEntitySourcePath(directory, entityId) {
370
+ return `${directory}/${entityId}.md`;
371
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "description": "Kibi CLI for knowledge base management",
6
6
  "engines": {
@@ -79,9 +79,18 @@
79
79
  "types": "./dist/public/brief-config.d.ts",
80
80
  "default": "./dist/public/brief-config.js"
81
81
  },
82
+ "./schema-version": {
83
+ "types": "./dist/public/schema-version.d.ts",
84
+ "default": "./dist/public/schema-version.js"
85
+ },
82
86
  "./operational-artifacts": {
83
87
  "types": "./dist/public/operational-artifacts.d.ts",
84
88
  "default": "./dist/public/operational-artifacts.js"
89
+ },
90
+ "./ignore-policy": {
91
+ "types": "./dist/public/ignore-policy.d.ts",
92
+ "import": "./dist/public/ignore-policy.js",
93
+ "default": "./dist/public/ignore-policy.js"
85
94
  }
86
95
  },
87
96
  "types": "./dist/cli.d.ts",
@@ -92,8 +101,9 @@
92
101
  "fast-glob": "^3.2.12",
93
102
  "gray-matter": "^4.0.3",
94
103
  "js-yaml": "^4.1.0",
95
- "kibi-core": "^0.5.2",
96
- "ts-morph": "^23.0.0"
104
+ "kibi-core": "^0.5.3",
105
+ "ts-morph": "^23.0.0",
106
+ "ignore": "^5.3.0"
97
107
  },
98
108
  "devDependencies": {
99
109
  "@types/node": "latest",
@@ -186,6 +186,11 @@
186
186
  "type": "boolean",
187
187
  "description": "Detect requirements with incomplete strict subject/property fact pairing for contradiction-safe semantics",
188
188
  "default": false
189
+ },
190
+ "strict-readiness": {
191
+ "type": "boolean",
192
+ "description": "Report strict contradiction-readiness levels for requirements that are still prose-only or otherwise not contradiction-ready",
193
+ "default": false
189
194
  }
190
195
  },
191
196
  "additionalProperties": false
@@ -229,7 +234,9 @@
229
234
  "required-fields": true,
230
235
  "deprecated-adr-no-successor": true,
231
236
  "domain-contradictions": true,
232
- "strict-fact-shape": false
237
+ "strict-fact-shape": false,
238
+ "strict-req-fact-pairing": false,
239
+ "strict-readiness": false
233
240
  },
234
241
  "symbolTraceability": {
235
242
  "requireAdr": false
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  /**
20
- * Public re-export barrel for shared check types.
20
+ * Public re-export barrel for shared check types and MCP-consumed modeling helpers.
21
21
  * Import from "kibi-cli/public/check-types" in MCP or external consumers.
22
22
  */
23
23
  export type {
@@ -26,6 +26,12 @@ export type {
26
26
  SymbolTraceabilityOptions,
27
27
  Violation,
28
28
  } from "../utils/rule-registry.js";
29
+ export type {
30
+ SemanticClaim,
31
+ StableRequirementIds,
32
+ StrictModelInput,
33
+ StrictWriteSet,
34
+ } from "../utils/strict-modeling.js";
29
35
 
30
36
  export {
31
37
  DEFAULT_CHECKS_CONFIG,
@@ -35,3 +41,11 @@ export {
35
41
  mergeChecksConfig,
36
42
  validateRuleName,
37
43
  } from "../utils/rule-registry.js";
44
+
45
+ export {
46
+ buildStableRequirementIds,
47
+ buildStrictWriteSet,
48
+ modelRequirementClaims,
49
+ normalizePropertyKey,
50
+ normalizeSubjectKey,
51
+ } from "../utils/strict-modeling.js";
@@ -19,6 +19,8 @@
19
19
  export {
20
20
  extractFromManifest,
21
21
  extractFromManifestString,
22
+ readManifestWithCoordinateOverlay,
22
23
  type ExtractionResult,
23
24
  type ManifestError,
25
+ type ManifestSymbolRecord,
24
26
  } from "../../extractors/manifest.js";
@@ -0,0 +1,229 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import ignore from "ignore";
4
+
5
+ const HARD_DENYLIST = [
6
+ ".kb",
7
+ ".git",
8
+ "node_modules",
9
+ "vendor",
10
+ "third_party",
11
+ ".sisyphus",
12
+ ".opencode",
13
+ ];
14
+
15
+ export interface IgnorePolicy {
16
+ isIgnored(inputPath: string): boolean;
17
+ getFastGlobIgnoreGlobs(): string[];
18
+ explain(inputPath: string): { ignored: boolean; reason?: string };
19
+ }
20
+
21
+ function readIgnoreFileLines(filePath: string): string[] {
22
+ if (!existsSync(filePath)) return [];
23
+ try {
24
+ const content = readFileSync(filePath, "utf8");
25
+ return content
26
+ .split(/\r?\n/)
27
+ .map((l) => l.trim())
28
+ .filter((l) => l.length > 0 && !l.startsWith("#"));
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function toPosix(p: string): string {
35
+ return p.split(path.sep).join("/");
36
+ }
37
+
38
+ // implements REQ-001
39
+ export function createRepoIgnorePolicy(workspaceRoot: string): IgnorePolicy {
40
+ const root = path.resolve(workspaceRoot);
41
+
42
+ // Load root .gitignore
43
+ const rootGitignorePath = path.join(root, ".gitignore");
44
+ const rootGitPatterns = readIgnoreFileLines(rootGitignorePath);
45
+
46
+ // Load .git/info/exclude
47
+ const gitInfoExcludePath = path.join(root, ".git", "info", "exclude");
48
+ const gitInfoPatterns = readIgnoreFileLines(gitInfoExcludePath);
49
+
50
+ // Find nested .gitignore files (skip scanning inside hard denylist directories)
51
+ const nestedPatterns = new Map<string, string[]>();
52
+
53
+ function walk(dirAbs: string) {
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(dirAbs, { withFileTypes: true });
57
+ } catch {
58
+ return;
59
+ }
60
+ for (const ent of entries) {
61
+ const name = String(ent.name);
62
+ const abs = path.join(dirAbs, name);
63
+ if (ent.isDirectory()) {
64
+ // avoid descending into common heavy or control directories
65
+ if (HARD_DENYLIST.includes(name)) continue;
66
+ // also avoid .git itself to prevent reading internal excludes as nested
67
+ if (name === ".git") continue;
68
+ walk(abs);
69
+ } else if (ent.isFile()) {
70
+ if (name === ".gitignore") {
71
+ // skip root .gitignore (we already loaded it)
72
+ if (path.resolve(dirAbs) === root) continue;
73
+ const patterns = readIgnoreFileLines(abs);
74
+ const relDir = path.relative(root, dirAbs) || ".";
75
+ nestedPatterns.set(toPosix(relDir), patterns);
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ walk(root);
82
+
83
+ // Create ignore instances
84
+ const rootIgnore = ignore();
85
+ if (rootGitPatterns.length > 0) rootIgnore.add(rootGitPatterns);
86
+
87
+ const gitInfoIgnore = ignore();
88
+ if (gitInfoPatterns.length > 0) gitInfoIgnore.add(gitInfoPatterns);
89
+
90
+ const nestedIgnoreMap = new Map<string, ReturnType<typeof ignore>>();
91
+ for (const [dirRel, pats] of nestedPatterns.entries()) {
92
+ const ig = ignore();
93
+ if (pats.length > 0) ig.add(pats);
94
+ nestedIgnoreMap.set(dirRel, ig);
95
+ }
96
+
97
+ // Prepare nested directories sorted by specificity (longest first)
98
+ const nestedDirsSorted = Array.from(nestedIgnoreMap.keys()).sort((a, b) => b.length - a.length);
99
+
100
+ function isPathOutsideWorkspace(absPath: string): boolean {
101
+ const rel = path.relative(root, absPath);
102
+ // path.relative returns paths starting with '..' for outside
103
+ return rel === "" ? false : rel.split(path.sep)[0] === "..";
104
+ }
105
+
106
+ function matchesHardDeny(relPosix: string): boolean {
107
+ const segments = relPosix.split("/").filter(Boolean);
108
+ for (const deny of HARD_DENYLIST) {
109
+ if (segments.includes(deny)) return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ function isIgnoredInternal(inputPath: string): { ignored: boolean; reason?: string } {
115
+ // Resolve to absolute and relative path inside workspace
116
+ const abs = path.isAbsolute(inputPath) ? path.resolve(inputPath) : path.resolve(root, inputPath);
117
+
118
+ if (path.isAbsolute(inputPath) && isPathOutsideWorkspace(abs)) {
119
+ return { ignored: true, reason: "outside_workspace" };
120
+ }
121
+
122
+ const rel = path.relative(root, abs) || ".";
123
+ const relPosix = toPosix(rel);
124
+
125
+ // Hard denylist always wins
126
+ if (matchesHardDeny(relPosix)) return { ignored: true, reason: "hard_deny" };
127
+
128
+ // Root .gitignore
129
+ try {
130
+ if (rootGitPatterns.length > 0 && rootIgnore.ignores(relPosix)) {
131
+ return { ignored: true, reason: "gitignored" };
132
+ }
133
+ } catch (e) {
134
+ // ignore errors from library usage; continue
135
+ }
136
+
137
+ // .git/info/exclude
138
+ try {
139
+ if (gitInfoPatterns.length > 0 && gitInfoIgnore.ignores(relPosix)) {
140
+ return { ignored: true, reason: "git_info_exclude" };
141
+ }
142
+ } catch (e) {
143
+ // noop
144
+ }
145
+
146
+ // Nested .gitignore (apply relative to their directory)
147
+ for (const dirRel of nestedDirsSorted) {
148
+ // dirRel is '.' for nested at root which we skipped, so dirRel will be like 'docs'
149
+ if (dirRel === ".") continue;
150
+ if (relPosix === dirRel || relPosix.startsWith(dirRel + "/")) {
151
+ const sub = relPosix === dirRel ? "." : relPosix.slice(dirRel.length + 1);
152
+ const ig = nestedIgnoreMap.get(dirRel)!;
153
+ try {
154
+ if (ig && ig.ignores(sub)) return { ignored: true, reason: "gitignored" };
155
+ } catch (e) {
156
+ // noop
157
+ }
158
+ }
159
+ }
160
+
161
+ return { ignored: false };
162
+ }
163
+
164
+ function getFastGlobIgnoreGlobs(): string[] {
165
+ const globs: string[] = [];
166
+
167
+ // Hard denylist globs
168
+ for (const d of HARD_DENYLIST) {
169
+ // match directory and its contents anywhere
170
+ globs.push(`**/${d}/**`);
171
+ globs.push(`**/${d}`);
172
+ }
173
+
174
+ // Root .gitignore patterns (convert to simple globs)
175
+ for (const p of rootGitPatterns) {
176
+ if (!p || p.startsWith("#") || p.startsWith("!")) continue;
177
+ let pat = p;
178
+ if (pat.startsWith("/")) pat = pat.slice(1);
179
+ if (pat.includes("/")) {
180
+ // anchored path
181
+ globs.push(`**/${toPosix(pat)}`);
182
+ } else {
183
+ globs.push(`**/${pat}`);
184
+ }
185
+ }
186
+
187
+ // .git/info/exclude patterns
188
+ for (const p of gitInfoPatterns) {
189
+ if (!p || p.startsWith("#") || p.startsWith("!")) continue;
190
+ let pat = p;
191
+ if (pat.startsWith("/")) pat = pat.slice(1);
192
+ if (pat.includes("/")) {
193
+ globs.push(`**/${toPosix(pat)}`);
194
+ } else {
195
+ globs.push(`**/${pat}`);
196
+ }
197
+ }
198
+
199
+ // Nested .gitignore patterns - prefix with directory path
200
+ // Use the raw patterns collected in nestedPatterns so we scope patterns
201
+ // to the nested directory instead of ignoring the entire directory.
202
+ // Debug: print nested patterns and the computed globs to help diagnosing test failures.
203
+ for (const [dirRel, patterns] of nestedPatterns.entries()) {
204
+ for (const p of patterns) {
205
+ if (!p || p.startsWith("#") || p.startsWith("!")) continue;
206
+ let pat = p;
207
+ if (pat.startsWith("/")) pat = pat.slice(1);
208
+ const prefix = dirRel === "." ? "" : `${dirRel}/`;
209
+ if (pat.includes("/")) {
210
+ globs.push(`**/${prefix}${toPosix(pat)}`);
211
+ } else {
212
+ globs.push(`**/${prefix}${pat}`);
213
+ }
214
+ }
215
+ }
216
+
217
+ return Array.from(new Set(globs));
218
+ }
219
+
220
+ return {
221
+ isIgnored(inputPath: string) {
222
+ return isIgnoredInternal(inputPath).ignored;
223
+ },
224
+ getFastGlobIgnoreGlobs,
225
+ explain(inputPath: string) {
226
+ return isIgnoredInternal(inputPath);
227
+ },
228
+ };
229
+ }