@oscharko-dev/keiko-workspace 0.2.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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/binaryDetect.d.ts +6 -0
- package/dist/binaryDetect.d.ts.map +1 -0
- package/dist/binaryDetect.js +20 -0
- package/dist/contextPack.d.ts +24 -0
- package/dist/contextPack.d.ts.map +1 -0
- package/dist/contextPack.js +118 -0
- package/dist/detect.d.ts +5 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +144 -0
- package/dist/discovery.d.ts +10 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +199 -0
- package/dist/document-extraction.d.ts +44 -0
- package/dist/document-extraction.d.ts.map +1 -0
- package/dist/document-extraction.js +372 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/fs.d.ts +25 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +69 -0
- package/dist/gitHistory.d.ts +3 -0
- package/dist/gitHistory.d.ts.map +1 -0
- package/dist/gitHistory.js +317 -0
- package/dist/ignore.d.ts +15 -0
- package/dist/ignore.d.ts.map +1 -0
- package/dist/ignore.js +248 -0
- package/dist/importGraph.d.ts +3 -0
- package/dist/importGraph.d.ts.map +1 -0
- package/dist/importGraph.js +131 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +38 -0
- package/dist/realpath.d.ts +9 -0
- package/dist/realpath.d.ts.map +1 -0
- package/dist/realpath.js +72 -0
- package/dist/repoSearch.d.ts +46 -0
- package/dist/repoSearch.d.ts.map +1 -0
- package/dist/repoSearch.js +350 -0
- package/dist/repoSearchEntries.d.ts +15 -0
- package/dist/repoSearchEntries.d.ts.map +1 -0
- package/dist/repoSearchEntries.js +106 -0
- package/dist/repoSearchLineSelection.d.ts +18 -0
- package/dist/repoSearchLineSelection.d.ts.map +1 -0
- package/dist/repoSearchLineSelection.js +43 -0
- package/dist/repoSearchMatchers.d.ts +8 -0
- package/dist/repoSearchMatchers.d.ts.map +1 -0
- package/dist/repoSearchMatchers.js +414 -0
- package/dist/repoSearchPolicy.d.ts +34 -0
- package/dist/repoSearchPolicy.d.ts.map +1 -0
- package/dist/repoSearchPolicy.js +342 -0
- package/dist/repoSearchRegexSafety.d.ts +2 -0
- package/dist/repoSearchRegexSafety.d.ts.map +1 -0
- package/dist/repoSearchRegexSafety.js +15 -0
- package/dist/repoSearchScan.d.ts +62 -0
- package/dist/repoSearchScan.d.ts.map +1 -0
- package/dist/repoSearchScan.js +292 -0
- package/dist/retrieval.d.ts +10 -0
- package/dist/retrieval.d.ts.map +1 -0
- package/dist/retrieval.js +74 -0
- package/dist/stableId.d.ts +4 -0
- package/dist/stableId.d.ts.map +1 -0
- package/dist/stableId.js +49 -0
- package/dist/structuralAdapters.d.ts +27 -0
- package/dist/structuralAdapters.d.ts.map +1 -0
- package/dist/structuralAdapters.js +87 -0
- package/dist/summary.d.ts +4 -0
- package/dist/summary.d.ts.map +1 -0
- package/dist/summary.js +54 -0
- package/dist/testSourcePairing.d.ts +3 -0
- package/dist/testSourcePairing.d.ts.map +1 -0
- package/dist/testSourcePairing.js +179 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/package.json +35 -0
package/dist/fs.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The single filesystem boundary for the workspace layer (ADR-0005 D1). Every other module
|
|
2
|
+
// depends on the `WorkspaceFs` port, never on `node:fs` directly, so discovery/detection are
|
|
3
|
+
// testable with an in-memory fake and all real IO is auditable in one place. Synchronous, to
|
|
4
|
+
// mirror the existing `loadConfigFromFile`/`readFileSync` usage in the gateway.
|
|
5
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { open } from "node:fs/promises";
|
|
7
|
+
function isSymlink(absolutePath) {
|
|
8
|
+
return lstatSync(absolutePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false;
|
|
9
|
+
}
|
|
10
|
+
export const nodeWorkspaceFs = {
|
|
11
|
+
readFileUtf8: (absolutePath) => readFileSync(absolutePath, "utf8"),
|
|
12
|
+
stat: (absolutePath) => {
|
|
13
|
+
const stats = statSync(absolutePath, { throwIfNoEntry: true });
|
|
14
|
+
return {
|
|
15
|
+
size: stats.size,
|
|
16
|
+
isFile: stats.isFile(),
|
|
17
|
+
isDirectory: stats.isDirectory(),
|
|
18
|
+
isSymbolicLink: isSymlink(absolutePath),
|
|
19
|
+
hardLinkCount: stats.nlink,
|
|
20
|
+
mtimeMs: stats.mtimeMs,
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
readDir: (absolutePath) => readdirSync(absolutePath, { withFileTypes: true }).map((entry) => ({
|
|
24
|
+
name: entry.name,
|
|
25
|
+
isDirectory: entry.isDirectory(),
|
|
26
|
+
isFile: entry.isFile(),
|
|
27
|
+
isSymbolicLink: entry.isSymbolicLink(),
|
|
28
|
+
})),
|
|
29
|
+
realPath: (absolutePath) => realpathSync(absolutePath),
|
|
30
|
+
exists: (absolutePath) => {
|
|
31
|
+
try {
|
|
32
|
+
return statSync(absolutePath, { throwIfNoEntry: false }) !== undefined;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
readFileBytes: async (absolutePath, maxBytes) => {
|
|
39
|
+
const handle = await open(absolutePath, "r");
|
|
40
|
+
try {
|
|
41
|
+
const cap = Math.max(0, Math.floor(maxBytes));
|
|
42
|
+
const buffer = new Uint8Array(cap);
|
|
43
|
+
if (cap === 0) {
|
|
44
|
+
return buffer;
|
|
45
|
+
}
|
|
46
|
+
const { bytesRead } = await handle.read(buffer, 0, cap, 0);
|
|
47
|
+
return buffer.subarray(0, bytesRead);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await handle.close();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
readFileRange: async (absolutePath, startByte, length) => {
|
|
54
|
+
const handle = await open(absolutePath, "r");
|
|
55
|
+
try {
|
|
56
|
+
const offset = Math.max(0, Math.floor(startByte));
|
|
57
|
+
const cap = Math.max(0, Math.floor(length));
|
|
58
|
+
const buffer = new Uint8Array(cap);
|
|
59
|
+
if (cap === 0) {
|
|
60
|
+
return buffer;
|
|
61
|
+
}
|
|
62
|
+
const { bytesRead } = await handle.read(buffer, 0, cap, offset);
|
|
63
|
+
return buffer.subarray(0, bytesRead);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await handle.close();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gitHistory.d.ts","sourceRoot":"","sources":["../src/gitHistory.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,iBAAiB,EAAyB,MAAM,yBAAyB,CAAC;AA2SxF,eAAO,MAAM,iBAAiB,EAAE,iBAsD/B,CAAC"}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Git-history adapter (Epic #177, Issue #180). Reads `.git/HEAD` and `.git/logs/HEAD` directly
|
|
2
|
+
// via the WorkspaceFs port — never spawns `git` and never imports `child_process`. The shared
|
|
3
|
+
// always-on deny list refuses `.git`; this adapter is the SOLE legitimate consumer of those
|
|
4
|
+
// paths and therefore goes through the lower-level `fs.readFileUtf8` after `assertContainedRealPath`,
|
|
5
|
+
// applies an explicit stat-based size cap, and redacts the contents. v1 surfaces the presence
|
|
6
|
+
// of a reflog as a single EvidenceAtom referencing `.git/HEAD`; per-file granularity deferred.
|
|
7
|
+
// Stays within ADR-0019 rule 3b: imports only @oscharko-dev/keiko-contracts, sibling workspace
|
|
8
|
+
// modules, and Node stdlib (node:crypto). Limitation: unavailable when scope.relativePaths is
|
|
9
|
+
// non-empty because git-history is a repo-level signal that cannot meaningfully scope to a
|
|
10
|
+
// sub-folder.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { isAbsolute, normalize } from "node:path";
|
|
13
|
+
import { redact } from "@oscharko-dev/keiko-security";
|
|
14
|
+
import { resolveWithinWorkspace } from "./paths.js";
|
|
15
|
+
import { assertContainedRealPath } from "./realpath.js";
|
|
16
|
+
import { buildAtom } from "./repoSearchScan.js";
|
|
17
|
+
function queryFingerprint(query) {
|
|
18
|
+
const canonical = JSON.stringify({ kind: query.kind, text: query.text });
|
|
19
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
20
|
+
}
|
|
21
|
+
const GIT_DIR_PREFIX = "gitdir:";
|
|
22
|
+
const HEAD_MAX_BYTES = 256;
|
|
23
|
+
const GIT_POINTER_MAX_BYTES = 4096;
|
|
24
|
+
const REFLOG_MAX_BYTES = 1_048_576;
|
|
25
|
+
const REFLOG_MAX_LINES = 10_000;
|
|
26
|
+
function isAllowedExternalGitdir(candidate) {
|
|
27
|
+
return candidate.replace(/\\/g, "/").includes("/.git/worktrees/");
|
|
28
|
+
}
|
|
29
|
+
function containsParentTraversal(candidate) {
|
|
30
|
+
return candidate.split(/[\\/]+/).includes("..");
|
|
31
|
+
}
|
|
32
|
+
async function readGuardedAbsolute(fs, base, absolutePath, label, maxBytes) {
|
|
33
|
+
try {
|
|
34
|
+
assertContainedRealPath(fs, base, absolutePath, label);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (!fs.exists(absolutePath)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const stat = fs.stat(absolutePath);
|
|
43
|
+
if (!stat.isFile) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (stat.hardLinkCount !== undefined && stat.hardLinkCount > 1) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
// Enforce the size cap BEFORE reading to avoid loading multi-megabyte files into memory
|
|
50
|
+
// (matches the probeBinary pattern from repoSearchScan.ts).
|
|
51
|
+
if (stat.size > maxBytes) {
|
|
52
|
+
if (fs.readFileBytes !== undefined) {
|
|
53
|
+
let bytes;
|
|
54
|
+
try {
|
|
55
|
+
bytes = await fs.readFileBytes(absolutePath, maxBytes);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return redact(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = fs.readFileUtf8(absolutePath);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return redact(raw);
|
|
72
|
+
}
|
|
73
|
+
function statOrUndefined(fs, abs) {
|
|
74
|
+
try {
|
|
75
|
+
const stat = fs.stat(abs);
|
|
76
|
+
return {
|
|
77
|
+
size: stat.size,
|
|
78
|
+
isFile: stat.isFile,
|
|
79
|
+
isDirectory: stat.isDirectory,
|
|
80
|
+
hardLinkCount: stat.hardLinkCount,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function readSmallUtf8File(fs, abs, maxBytes) {
|
|
88
|
+
const stat = statOrUndefined(fs, abs);
|
|
89
|
+
if (!stat?.isFile) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (stat.hardLinkCount !== undefined && stat.hardLinkCount > 1) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if (stat.size > maxBytes) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if (fs.readFileBytes !== undefined) {
|
|
99
|
+
try {
|
|
100
|
+
const bytes = await fs.readFileBytes(abs, maxBytes);
|
|
101
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return fs.readFileUtf8(abs);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function readWorktreePointerTarget(fs, dotGit) {
|
|
115
|
+
const raw = await readSmallUtf8File(fs, dotGit, GIT_POINTER_MAX_BYTES);
|
|
116
|
+
if (raw === undefined) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const trimmed = raw.trim();
|
|
120
|
+
if (!trimmed.startsWith(GIT_DIR_PREFIX)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const target = trimmed.slice(GIT_DIR_PREFIX.length).trim();
|
|
124
|
+
if (target.length === 0 || target.includes("\n")) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
return target;
|
|
128
|
+
}
|
|
129
|
+
function containedPathIfPresent(fs, base, abs, label) {
|
|
130
|
+
try {
|
|
131
|
+
const contained = assertContainedRealPath(fs, base, abs, label);
|
|
132
|
+
if (!fs.exists(contained)) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return contained;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function isContainedAndPresent(fs, base, abs, label) {
|
|
142
|
+
return containedPathIfPresent(fs, base, abs, label) !== undefined;
|
|
143
|
+
}
|
|
144
|
+
function resolvePointedGitdir(fs, root, target, candidate) {
|
|
145
|
+
if (!isAbsolute(target)) {
|
|
146
|
+
try {
|
|
147
|
+
return assertContainedRealPath(fs, root, candidate, ".git pointer");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (containsParentTraversal(target)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
const contained = containedPathIfPresent(fs, root, candidate, ".git pointer");
|
|
157
|
+
if (contained !== undefined) {
|
|
158
|
+
return contained;
|
|
159
|
+
}
|
|
160
|
+
let canonical;
|
|
161
|
+
try {
|
|
162
|
+
canonical = fs.realPath(candidate);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
return isAllowedExternalGitdir(canonical) ? canonical : undefined;
|
|
168
|
+
}
|
|
169
|
+
// Find the first 10-digit run that is not preceded by '<'. Avoids regex backtracking.
|
|
170
|
+
function firstUnixTimestamp(line) {
|
|
171
|
+
let i = 0;
|
|
172
|
+
const len = line.length;
|
|
173
|
+
while (i < len) {
|
|
174
|
+
const code = line.charCodeAt(i);
|
|
175
|
+
if (code >= 48 && code <= 57) {
|
|
176
|
+
let j = i;
|
|
177
|
+
while (j < len && line.charCodeAt(j) >= 48 && line.charCodeAt(j) <= 57) {
|
|
178
|
+
j += 1;
|
|
179
|
+
}
|
|
180
|
+
if (j - i === 10) {
|
|
181
|
+
const prev = i === 0 ? "" : line.charAt(i - 1);
|
|
182
|
+
if (prev !== "<") {
|
|
183
|
+
return Number.parseInt(line.slice(i, j), 10);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
i = j;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
i += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
function extractTimestamps(reflog) {
|
|
195
|
+
const out = [];
|
|
196
|
+
let lineCount = 0;
|
|
197
|
+
for (const line of reflog.split("\n")) {
|
|
198
|
+
if (lineCount >= REFLOG_MAX_LINES) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
lineCount += 1;
|
|
202
|
+
if (line.length === 0) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const ts = firstUnixTimestamp(line);
|
|
206
|
+
if (ts !== undefined) {
|
|
207
|
+
out.push(ts);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function gitHeadAtom(scope, fingerprint, nowMs) {
|
|
213
|
+
return buildAtom({
|
|
214
|
+
scopeId: scope.scopeId,
|
|
215
|
+
scopePath: ".git/HEAD",
|
|
216
|
+
lineRange: undefined,
|
|
217
|
+
provenanceKind: "git-history",
|
|
218
|
+
tool: "git-reflog",
|
|
219
|
+
queryFingerprint: fingerprint,
|
|
220
|
+
score: 1.0,
|
|
221
|
+
emittedAtMs: nowMs,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Resolve the gitdir root: for a plain repo it is `workspace.root/.git/`; for a worktree
|
|
225
|
+
// it is the path pointed at by the `.git` pointer file. Returns undefined when unavailable.
|
|
226
|
+
// Strategy: check whether HEAD lives directly at `.git/HEAD` first (covers the normal case AND
|
|
227
|
+
// the memFs directory simulation where only child keys are recorded); fall back to treating
|
|
228
|
+
// `.git` as a worktree-pointer file only when that leaf check fails.
|
|
229
|
+
async function resolveGitdir(fs, root) {
|
|
230
|
+
const dotGit = resolveWithinWorkspace(root, ".git");
|
|
231
|
+
const headDirect = `${dotGit}/HEAD`;
|
|
232
|
+
// Fast path: HEAD exists directly under .git — this is the standard directory layout.
|
|
233
|
+
// We do NOT require .git itself to appear as a stat entry (some WorkspaceFs impls, notably
|
|
234
|
+
// the test memFs, only record leaf file paths, not implicit parent directories).
|
|
235
|
+
if (isContainedAndPresent(fs, root, headDirect, ".git/HEAD")) {
|
|
236
|
+
return dotGit;
|
|
237
|
+
}
|
|
238
|
+
// Slow path: .git must be a regular file (worktree pointer). It must exist AND be readable.
|
|
239
|
+
if (!isContainedAndPresent(fs, root, dotGit, ".git")) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const s = statOrUndefined(fs, dotGit);
|
|
243
|
+
if (!s?.isFile) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
// Worktree-pointer: read the `gitdir: <path>` value, validate containment once.
|
|
247
|
+
const target = await readWorktreePointerTarget(fs, dotGit);
|
|
248
|
+
if (target === undefined) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const candidate = isAbsolute(target) ? normalize(target) : resolveWithinWorkspace(root, target);
|
|
252
|
+
// Real git worktrees usually point outside the checkout root to `.git/worktrees/<name>`.
|
|
253
|
+
// Allow that one narrow shape, but still constrain the actual reads to files whose realpaths
|
|
254
|
+
// stay inside the resolved gitdir itself.
|
|
255
|
+
const gitdir = resolvePointedGitdir(fs, root, target, candidate);
|
|
256
|
+
if (gitdir === undefined) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
const pointedHead = `${gitdir}/HEAD`;
|
|
260
|
+
if (!isContainedAndPresent(fs, gitdir, pointedHead, ".git-pointer/HEAD")) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
return gitdir;
|
|
264
|
+
}
|
|
265
|
+
async function isAvailableForScope(scope, fs) {
|
|
266
|
+
// Finding 8: git-history is a repo-level signal; sub-folder scoping is meaningless and
|
|
267
|
+
// would require reading outside the user-selected boundary.
|
|
268
|
+
if (scope.relativePaths.length > 0) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
const root = scope.workspace.root;
|
|
272
|
+
const gitdir = await resolveGitdir(fs, root);
|
|
273
|
+
if (gitdir === undefined) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
// HEAD must exist inside the resolved gitdir.
|
|
277
|
+
const headAbs = `${gitdir}/HEAD`;
|
|
278
|
+
return isContainedAndPresent(fs, gitdir, headAbs, ".git/HEAD");
|
|
279
|
+
}
|
|
280
|
+
export const gitHistoryAdapter = {
|
|
281
|
+
name: "git-history",
|
|
282
|
+
isAvailable: async (scope, fs) => {
|
|
283
|
+
try {
|
|
284
|
+
return await isAvailableForScope(scope, fs);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
lookup: async (scope, query, limits, fs, deps) => {
|
|
291
|
+
void limits;
|
|
292
|
+
const nowMs = deps?.nowMs ?? Date.now;
|
|
293
|
+
// Finding 8: early-out when scope has sub-paths (matches isAvailable contract).
|
|
294
|
+
if (scope.relativePaths.length > 0) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const root = scope.workspace.root;
|
|
298
|
+
// Finding 7: resolve the real gitdir so worktree-pointer layouts work end-to-end.
|
|
299
|
+
const gitdir = await resolveGitdir(fs, root);
|
|
300
|
+
if (gitdir === undefined) {
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
const head = await readGuardedAbsolute(fs, gitdir, `${gitdir}/HEAD`, ".git/HEAD", HEAD_MAX_BYTES);
|
|
304
|
+
if (head === undefined) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const reflog = await readGuardedAbsolute(fs, gitdir, `${gitdir}/logs/HEAD`, ".git/logs/HEAD", REFLOG_MAX_BYTES);
|
|
308
|
+
if (reflog === undefined || reflog.length === 0) {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
const timestamps = extractTimestamps(reflog);
|
|
312
|
+
if (timestamps.length === 0) {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
return [gitHeadAtom(scope, queryFingerprint(query), nowMs())];
|
|
316
|
+
},
|
|
317
|
+
};
|
package/dist/ignore.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const DEFAULT_DENY_PATTERNS: readonly string[];
|
|
2
|
+
export declare function isDenied(relPath: string): boolean;
|
|
3
|
+
interface IgnoreRule {
|
|
4
|
+
readonly regex: RegExp;
|
|
5
|
+
readonly dirEntryRegex: RegExp | null;
|
|
6
|
+
readonly negated: boolean;
|
|
7
|
+
readonly dirOnly: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface IgnoreMatcher {
|
|
10
|
+
readonly rules: readonly IgnoreRule[];
|
|
11
|
+
}
|
|
12
|
+
export declare function compileIgnore(lines: readonly string[]): IgnoreMatcher;
|
|
13
|
+
export declare function isIgnored(matcher: IgnoreMatcher, relPath: string, isDir: boolean): boolean;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=ignore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ignore.d.ts","sourceRoot":"","sources":["../src/ignore.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAmGjD,CAAC;AA+BH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAajD;AAMD,UAAU,UAAU;IAClB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAGvB,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAC;CACvC;AAkFD,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,aAAa,CASrE;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAS1F"}
|
package/dist/ignore.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// PURE filtering. Two tiers (ADR-0005 D3):
|
|
2
|
+
// 1. isDenied() — ALWAYS-ON security deny list. Enforced before every read regardless
|
|
3
|
+
// of .gitignore. Secret/dep/build/cache/vcs/log/os files are never discovered or read.
|
|
4
|
+
// 2. compileIgnore()/isIgnored() — best-effort noise reduction over a DOCUMENTED, bounded
|
|
5
|
+
// .gitignore subset. Never relaxes the deny list.
|
|
6
|
+
// Glob translation produces only linear regex pieces (`[^/]*`, `.*`) so there is no
|
|
7
|
+
// catastrophic backtracking (no ReDoS).
|
|
8
|
+
export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
9
|
+
// secrets
|
|
10
|
+
".env",
|
|
11
|
+
".env.*",
|
|
12
|
+
"*.pem",
|
|
13
|
+
"*.key",
|
|
14
|
+
"id_rsa",
|
|
15
|
+
"id_ed25519",
|
|
16
|
+
"id_ecdsa",
|
|
17
|
+
"id_dsa",
|
|
18
|
+
"*.p12",
|
|
19
|
+
"*.pfx",
|
|
20
|
+
".npmrc",
|
|
21
|
+
// credential directories & stores — Epic #532 makes any folder on the machine connectable, so
|
|
22
|
+
// the deny gate must keep well-known secret locations out of every tree listing, excerpt, and
|
|
23
|
+
// grounded answer even when the user points a Files window at their home directory.
|
|
24
|
+
".ssh",
|
|
25
|
+
".aws",
|
|
26
|
+
".gnupg",
|
|
27
|
+
".kube",
|
|
28
|
+
".azure",
|
|
29
|
+
".docker",
|
|
30
|
+
".netrc",
|
|
31
|
+
".pgpass",
|
|
32
|
+
".git-credentials",
|
|
33
|
+
"Keychains",
|
|
34
|
+
"*.keychain",
|
|
35
|
+
"*.keychain-db",
|
|
36
|
+
"*.keystore",
|
|
37
|
+
"*.jks",
|
|
38
|
+
// Epic #532 security audit (H1/M1): additional well-known credential stores reachable when a
|
|
39
|
+
// user connects their home directory. `.config` is the XDG base whose subdirectories (gcloud
|
|
40
|
+
// ADC, many per-app tokens) consistently hold secrets; the rest are exact credential locations.
|
|
41
|
+
".config",
|
|
42
|
+
".terraform",
|
|
43
|
+
".terraform.d",
|
|
44
|
+
".vault-token",
|
|
45
|
+
".cargo",
|
|
46
|
+
".pypirc",
|
|
47
|
+
".m2",
|
|
48
|
+
".password-store",
|
|
49
|
+
"id_ecdsa_sk",
|
|
50
|
+
"id_ed25519_sk",
|
|
51
|
+
// Epic #532 security audit (H1): full-machine browse makes pure-credential FILES reachable in
|
|
52
|
+
// otherwise-allowed locations (not just credential dirs). These filenames hold secrets or secret
|
|
53
|
+
// state by convention, so they are denied outright — never listed, previewed, or grounded. Secrets
|
|
54
|
+
// that are merely content-shaped elsewhere are caught by the redaction layer instead.
|
|
55
|
+
"*.tfstate",
|
|
56
|
+
"*.tfstate.backup",
|
|
57
|
+
"kubeconfig",
|
|
58
|
+
".rclone.conf",
|
|
59
|
+
"wp-config.php",
|
|
60
|
+
".htpasswd",
|
|
61
|
+
".bash_history",
|
|
62
|
+
".zsh_history",
|
|
63
|
+
".sh_history",
|
|
64
|
+
".mysql_history",
|
|
65
|
+
".psql_history",
|
|
66
|
+
".python_history",
|
|
67
|
+
".node_repl_history",
|
|
68
|
+
".irb_history",
|
|
69
|
+
"service-account*.json",
|
|
70
|
+
// Epic #177 post-closure audit: additional pure-credential FILES denied outright. These filenames
|
|
71
|
+
// hold secrets by convention (cloud storage keys, OAuth tokens, VCS/registry/shell credentials,
|
|
72
|
+
// IaC variables); secret-shaped content in otherwise-allowed files is still scrubbed by the
|
|
73
|
+
// redaction layer as defence-in-depth.
|
|
74
|
+
".s3cfg",
|
|
75
|
+
".boto",
|
|
76
|
+
".dockercfg",
|
|
77
|
+
".gitconfig",
|
|
78
|
+
".envrc",
|
|
79
|
+
"*.tfvars",
|
|
80
|
+
"*.tfvars.json",
|
|
81
|
+
".terraformrc",
|
|
82
|
+
// deps
|
|
83
|
+
"node_modules",
|
|
84
|
+
// Keiko runtime/evidence state. These files are internal product artifacts, not repository
|
|
85
|
+
// source, and must never be discoverable through connected repository grounding.
|
|
86
|
+
".keiko",
|
|
87
|
+
"keiko.config.json",
|
|
88
|
+
// Local tool and IDE runtime state. These directories can contain prompts, transcripts,
|
|
89
|
+
// worktrees, extension caches, shelves, and machine-local metadata; they are not source context.
|
|
90
|
+
".codex",
|
|
91
|
+
".claude",
|
|
92
|
+
".playwright-mcp",
|
|
93
|
+
".idea",
|
|
94
|
+
// build
|
|
95
|
+
"dist",
|
|
96
|
+
"build",
|
|
97
|
+
"out",
|
|
98
|
+
"coverage",
|
|
99
|
+
// caches
|
|
100
|
+
".cache",
|
|
101
|
+
".next",
|
|
102
|
+
".turbo",
|
|
103
|
+
// vcs
|
|
104
|
+
".git",
|
|
105
|
+
// os
|
|
106
|
+
".DS_Store",
|
|
107
|
+
]);
|
|
108
|
+
// Note: `*.log` is intentionally NOT denied — connected-context search must cover every text
|
|
109
|
+
// format, including log files. Secret-shaped strings inside any matched content are still scrubbed
|
|
110
|
+
// by the redaction layer before they reach an answer or the evidence ledger.
|
|
111
|
+
// Iterates from the end rather than using /\/+$/ to avoid quadratic ReDoS on
|
|
112
|
+
// inputs with many consecutive trailing slashes (CodeQL js/polynomial-redos).
|
|
113
|
+
function stripTrailingSlashes(value) {
|
|
114
|
+
let end = value.length;
|
|
115
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) {
|
|
116
|
+
end -= 1;
|
|
117
|
+
}
|
|
118
|
+
return value.slice(0, end);
|
|
119
|
+
}
|
|
120
|
+
function normalize(relPath) {
|
|
121
|
+
return stripTrailingSlashes(relPath.replace(/\\/g, "/").replace(/^\.\//, ""));
|
|
122
|
+
}
|
|
123
|
+
function segments(relPath) {
|
|
124
|
+
return normalize(relPath)
|
|
125
|
+
.split("/")
|
|
126
|
+
.filter((part) => part.length > 0);
|
|
127
|
+
}
|
|
128
|
+
const EXAMPLE_ENV = ".env.example";
|
|
129
|
+
// Always-on security check. Denied if ANY path segment matches a deny pattern (a denied
|
|
130
|
+
// directory denies everything under it). `.env.example` is the single documented exception.
|
|
131
|
+
// Matching is CASE-INSENSITIVE: on case-insensitive filesystems (macOS, Windows) `.ENV` and
|
|
132
|
+
// `.env` name the same file, so a case-only variant must not bypass the deny list.
|
|
133
|
+
export function isDenied(relPath) {
|
|
134
|
+
for (const part of segments(relPath)) {
|
|
135
|
+
const lower = part.toLowerCase();
|
|
136
|
+
if (lower === EXAMPLE_ENV) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const matcher of PRECOMPILED_DENY_MATCHERS) {
|
|
140
|
+
if (matcher.regex.test(lower)) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
function globBody(glob) {
|
|
148
|
+
let body = "";
|
|
149
|
+
let i = 0;
|
|
150
|
+
while (i < glob.length) {
|
|
151
|
+
// charAt returns "" past the end; the loop bound guarantees a real char here, so there is
|
|
152
|
+
// no dead nullish fallback. charAt(i + 1)/(i + 2) safely return "" at the boundary.
|
|
153
|
+
const char = glob.charAt(i);
|
|
154
|
+
if (char === "*" && glob.charAt(i + 1) === "*") {
|
|
155
|
+
body += ".*";
|
|
156
|
+
i += glob.charAt(i + 2) === "/" ? 3 : 2;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (char === "*") {
|
|
160
|
+
body += "[^/]*";
|
|
161
|
+
}
|
|
162
|
+
else if (char === "?") {
|
|
163
|
+
body += "[^/]";
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
body += escapeLiteral(char);
|
|
167
|
+
}
|
|
168
|
+
i += 1;
|
|
169
|
+
}
|
|
170
|
+
return body;
|
|
171
|
+
}
|
|
172
|
+
function globToRegExp(glob, anchored) {
|
|
173
|
+
const prefix = anchored ? "^" : "^(?:.*/)?";
|
|
174
|
+
return new RegExp(`${prefix}${globBody(glob)}(?:/.*)?$`);
|
|
175
|
+
}
|
|
176
|
+
const PRECOMPILED_DENY_MATCHERS = DEFAULT_DENY_PATTERNS.map((pattern) => ({
|
|
177
|
+
regex: globToRegExp(pattern.toLowerCase(), false),
|
|
178
|
+
}));
|
|
179
|
+
// Matches the path that names the directory itself (no nested suffix), used so a `dir/` rule
|
|
180
|
+
// can distinguish a real subdirectory from a same-named file at another depth.
|
|
181
|
+
function globToDirEntryRegExp(glob, anchored) {
|
|
182
|
+
const prefix = anchored ? "^" : "^(?:.*/)?";
|
|
183
|
+
return new RegExp(`${prefix}${globBody(glob)}$`);
|
|
184
|
+
}
|
|
185
|
+
function escapeLiteral(char) {
|
|
186
|
+
return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
|
|
187
|
+
}
|
|
188
|
+
function stripAnchors(value, anchored, dirOnly) {
|
|
189
|
+
let core = value;
|
|
190
|
+
if (anchored) {
|
|
191
|
+
core = core.slice(1);
|
|
192
|
+
}
|
|
193
|
+
if (dirOnly) {
|
|
194
|
+
core = core.slice(0, -1);
|
|
195
|
+
}
|
|
196
|
+
return core;
|
|
197
|
+
}
|
|
198
|
+
function buildRule(rawLine) {
|
|
199
|
+
const line = rawLine.trim();
|
|
200
|
+
if (line.length === 0 || line.startsWith("#")) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const negated = line.startsWith("!");
|
|
204
|
+
const afterBang = negated ? line.slice(1) : line;
|
|
205
|
+
const anchored = afterBang.startsWith("/");
|
|
206
|
+
const dirOnly = afterBang.endsWith("/");
|
|
207
|
+
const core = stripAnchors(afterBang, anchored, dirOnly);
|
|
208
|
+
if (core.length === 0) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
regex: globToRegExp(core, anchored),
|
|
213
|
+
dirEntryRegex: dirOnly ? globToDirEntryRegExp(core, anchored) : null,
|
|
214
|
+
negated,
|
|
215
|
+
dirOnly,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export function compileIgnore(lines) {
|
|
219
|
+
const rules = [];
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
const rule = buildRule(line);
|
|
222
|
+
if (rule !== null) {
|
|
223
|
+
rules.push(rule);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { rules };
|
|
227
|
+
}
|
|
228
|
+
export function isIgnored(matcher, relPath, isDir) {
|
|
229
|
+
const target = normalize(relPath);
|
|
230
|
+
let ignored = false;
|
|
231
|
+
for (const rule of matcher.rules) {
|
|
232
|
+
if (ruleMatches(rule, target, isDir)) {
|
|
233
|
+
ignored = !rule.negated;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return ignored;
|
|
237
|
+
}
|
|
238
|
+
// A `dir/` rule ignores either the matching directory entry itself (only when it IS a
|
|
239
|
+
// directory), or any path genuinely nested under it. A same-named FILE is not ignored.
|
|
240
|
+
function ruleMatches(rule, target, isDir) {
|
|
241
|
+
if (!rule.dirOnly) {
|
|
242
|
+
return rule.regex.test(target);
|
|
243
|
+
}
|
|
244
|
+
if (rule.dirEntryRegex?.test(target) === true) {
|
|
245
|
+
return isDir;
|
|
246
|
+
}
|
|
247
|
+
return rule.regex.test(target);
|
|
248
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"importGraph.d.ts","sourceRoot":"","sources":["../src/importGraph.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,iBAAiB,EAAyB,MAAM,yBAAyB,CAAC;AA+HxF,eAAO,MAAM,kBAAkB,EAAE,iBAkChC,CAAC"}
|