@davesheffer/hunch 1.8.3 → 1.9.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.
- package/README.md +92 -1
- package/dist/cli/index.js +1222 -397
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +6 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +53 -10
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +7 -2
- package/tooling/md1-benchmark.mjs +628 -0
package/dist/core/io.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/** Durable file writes for the Hunch. */
|
|
2
|
-
import { writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { linkSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
3
3
|
let counter = 0;
|
|
4
|
+
const renameRetryDelaysMs = [10, 20, 40, 80];
|
|
5
|
+
const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
4
6
|
/**
|
|
5
7
|
* Write `data` to `file` via a temp file + rename, so an interrupted write can't
|
|
6
8
|
* leave the target truncated (the symbols/edges index is the worst to half-write).
|
|
7
9
|
*
|
|
8
10
|
* Windows caveat: renameSync can't REPLACE a file another process holds open (even
|
|
9
11
|
* for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
|
|
10
|
-
* while a CLI writes.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* while a CLI writes. Retry that atomic replacement with bounded backoff. If the
|
|
13
|
+
* contention persists, fail with the old target untouched; never trade availability
|
|
14
|
+
* for a direct write that an interruption could truncate. Failed writes clean up the
|
|
15
|
+
* temporary file.
|
|
13
16
|
*/
|
|
14
17
|
export function writeFileAtomic(file, data) {
|
|
15
18
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
@@ -21,16 +24,48 @@ export function writeFileAtomic(file, data) {
|
|
|
21
24
|
throw e;
|
|
22
25
|
}
|
|
23
26
|
try {
|
|
24
|
-
|
|
27
|
+
renameWithContentionRetry(tmp, file);
|
|
25
28
|
}
|
|
26
29
|
catch (e) {
|
|
27
30
|
safeRm(tmp);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function renameWithContentionRetry(from, to) {
|
|
35
|
+
for (let attempt = 0;; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
renameSync(from, to);
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
|
-
|
|
40
|
+
catch (error) {
|
|
41
|
+
const delayMs = renameRetryDelaysMs[attempt];
|
|
42
|
+
if (delayMs === undefined || !isRenameContention(error))
|
|
43
|
+
throw error;
|
|
44
|
+
Atomics.wait(renameRetryWaiter, 0, 0, delayMs);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function isRenameContention(error) {
|
|
49
|
+
const code = error.code;
|
|
50
|
+
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
51
|
+
}
|
|
52
|
+
/** Atomically create a complete file only when no target exists. A same-dir
|
|
53
|
+
* hard link publishes the fully written temp inode with create-if-absent
|
|
54
|
+
* semantics, so concurrent lifecycle writers can never be overwritten. */
|
|
55
|
+
export function writeFileAtomicIfAbsent(file, data) {
|
|
56
|
+
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(tmp, data);
|
|
59
|
+
linkSync(tmp, file);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code === "EEXIST")
|
|
64
|
+
return false;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
safeRm(tmp);
|
|
34
69
|
}
|
|
35
70
|
}
|
|
36
71
|
function safeRm(p) {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { ENTITY_KINDS } from "./types.js";
|
|
4
|
+
function pathIsWithin(path, parent) {
|
|
5
|
+
const rel = relative(parent, path);
|
|
6
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
7
|
+
}
|
|
8
|
+
/** Validate the materialized overlay without following links. Git's metadata is
|
|
9
|
+
* deliberately skipped, but its own directory must still be contained under the
|
|
10
|
+
* canonical overlay root. Every remotely controlled entry must be an ordinary
|
|
11
|
+
* file or real directory whose canonical path stays inside that root. */
|
|
12
|
+
export function safeOverlayTree(root) {
|
|
13
|
+
try {
|
|
14
|
+
const lexicalRoot = resolve(root);
|
|
15
|
+
const rootStat = lstatSync(lexicalRoot);
|
|
16
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
|
|
17
|
+
return false;
|
|
18
|
+
const canonicalRoot = realpathSync(lexicalRoot);
|
|
19
|
+
const walk = (dir, topLevel = false) => {
|
|
20
|
+
const dirStat = lstatSync(dir);
|
|
21
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory())
|
|
22
|
+
return false;
|
|
23
|
+
if (!pathIsWithin(realpathSync(dir), canonicalRoot))
|
|
24
|
+
return false;
|
|
25
|
+
for (const name of readdirSync(dir)) {
|
|
26
|
+
const entry = join(dir, name);
|
|
27
|
+
const stat = lstatSync(entry);
|
|
28
|
+
if (stat.isSymbolicLink())
|
|
29
|
+
return false;
|
|
30
|
+
if (!pathIsWithin(realpathSync(entry), canonicalRoot))
|
|
31
|
+
return false;
|
|
32
|
+
if (topLevel && (name === ".gitignore" || name === ".gitattributes")
|
|
33
|
+
&& (!stat.isFile() || stat.nlink !== 1))
|
|
34
|
+
return false;
|
|
35
|
+
if (topLevel && name === ".hunch" && !stat.isDirectory())
|
|
36
|
+
return false;
|
|
37
|
+
if (topLevel && name === ".git") {
|
|
38
|
+
if (!stat.isDirectory())
|
|
39
|
+
return false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (stat.isDirectory()) {
|
|
43
|
+
if (!walk(entry))
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
else if (!stat.isFile()) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
if (!walk(lexicalRoot, true))
|
|
53
|
+
return false;
|
|
54
|
+
const hunchDir = join(lexicalRoot, ".hunch");
|
|
55
|
+
if (existsSync(hunchDir)) {
|
|
56
|
+
for (const kind of ENTITY_KINDS) {
|
|
57
|
+
const kindDir = join(hunchDir, kind);
|
|
58
|
+
if (existsSync(kindDir) && !lstatSync(kindDir).isDirectory())
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
for (const name of ["manifest.json", "config.json"]) {
|
|
62
|
+
const file = join(hunchDir, name);
|
|
63
|
+
if (existsSync(file) && !lstatSync(file).isFile())
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Validate `git ls-tree -r -t -z <exact-oid>` output before checkout. A Git
|
|
74
|
+
* remote can encode symlinks (120000) and gitlinks (160000); accepting only
|
|
75
|
+
* ordinary blobs and trees makes the fetched object graph safe to materialize.
|
|
76
|
+
* The explicit Hunch topology rules keep canonical record directories and
|
|
77
|
+
* capability/config paths from changing shape on a later pull. */
|
|
78
|
+
export function safeOverlayGitTreeListing(listing) {
|
|
79
|
+
const entries = new Map();
|
|
80
|
+
for (const row of listing.split("\0")) {
|
|
81
|
+
if (!row)
|
|
82
|
+
continue;
|
|
83
|
+
const tab = row.indexOf("\t");
|
|
84
|
+
if (tab <= 0)
|
|
85
|
+
return false;
|
|
86
|
+
const header = row.slice(0, tab).match(/^([0-7]{6}) (blob|tree|commit) ([0-9a-f]+)$/);
|
|
87
|
+
if (!header)
|
|
88
|
+
return false;
|
|
89
|
+
const path = row.slice(tab + 1);
|
|
90
|
+
const segments = path.split("/");
|
|
91
|
+
if (!path || path.startsWith("/") || path.includes("\\")
|
|
92
|
+
|| segments.some((segment) => !segment || segment === "." || segment === ".."
|
|
93
|
+
|| segment.toLowerCase() === ".git" || segment === ".hunch-commit.lock")) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
// These are clone-local/derived runtime artifacts, never graph source of
|
|
97
|
+
// truth. Accepting a tracked pointer can disclose or redirect a machine's
|
|
98
|
+
// private store; tracked SQLite/temp/cache artifacts poison the clean-tree
|
|
99
|
+
// and additive-publication contracts on every later request.
|
|
100
|
+
if (path === ".hunch/local.json"
|
|
101
|
+
|| path === ".hunch-cache" || path.startsWith(".hunch-cache/")
|
|
102
|
+
|| /^\.hunch\/[^/]+\.sqlite[^/]*$/i.test(path)
|
|
103
|
+
|| (path.startsWith(".hunch/") && segments.slice(1).some((segment) => segment.includes(".tmp")))) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const mode = header[1];
|
|
107
|
+
const type = header[2];
|
|
108
|
+
const ordinaryTree = mode === "040000" && type === "tree";
|
|
109
|
+
const ordinaryBlob = (mode === "100644" || mode === "100755") && type === "blob";
|
|
110
|
+
if (!ordinaryTree && !ordinaryBlob)
|
|
111
|
+
return false;
|
|
112
|
+
if (entries.has(path))
|
|
113
|
+
return false;
|
|
114
|
+
entries.set(path, type);
|
|
115
|
+
}
|
|
116
|
+
const isTree = (path) => !entries.has(path) || entries.get(path) === "tree";
|
|
117
|
+
const isBlob = (path) => !entries.has(path) || entries.get(path) === "blob";
|
|
118
|
+
if (!isTree(".hunch"))
|
|
119
|
+
return false;
|
|
120
|
+
for (const kind of ENTITY_KINDS)
|
|
121
|
+
if (!isTree(`.hunch/${kind}`))
|
|
122
|
+
return false;
|
|
123
|
+
for (const path of [".gitattributes", ".gitignore", ".hunch/manifest.json", ".hunch/config.json"]) {
|
|
124
|
+
if (!isBlob(path))
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** A dedicated Hunch overlay needs exactly one attribute capability: selecting
|
|
130
|
+
* the locally installed `merge=hunch` JSON merge driver, plus Hunch's exact
|
|
131
|
+
* `.hunch/manifest.json merge=text` override (the manifest has no record id and
|
|
132
|
+
* must use Git's built-in text merge). Reject every other token/pattern pair,
|
|
133
|
+
* including byte-transforming built-ins such as `ident` and
|
|
134
|
+
* `working-tree-encoding`, rather than maintaining a command-key blacklist.
|
|
135
|
+
* Blank lines and comments remain harmless. */
|
|
136
|
+
export function hunchAttributesAreSafe(content) {
|
|
137
|
+
for (const line of content.split(/\r?\n/)) {
|
|
138
|
+
const candidate = line.trimStart();
|
|
139
|
+
if (!candidate || candidate.startsWith("#"))
|
|
140
|
+
continue;
|
|
141
|
+
const fields = candidate.split(/\s+/);
|
|
142
|
+
if (fields.length < 2)
|
|
143
|
+
return false;
|
|
144
|
+
const attributes = fields.slice(1);
|
|
145
|
+
if (attributes.every((attribute) => attribute === "merge=hunch"))
|
|
146
|
+
continue;
|
|
147
|
+
if (fields[0] === ".hunch/manifest.json"
|
|
148
|
+
&& attributes.every((attribute) => attribute === "merge=text"))
|
|
149
|
+
continue;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/** Validate every committed .gitattributes blob in an already-safe ls-tree
|
|
155
|
+
* listing. Blob loading is injected so clone and later-pull seams share one
|
|
156
|
+
* parser without either trusting worktree bytes before materialization. */
|
|
157
|
+
export function hunchTreeAttributesAreSafe(listing, readBlob) {
|
|
158
|
+
if (!safeOverlayGitTreeListing(listing))
|
|
159
|
+
return false;
|
|
160
|
+
for (const row of listing.split("\0")) {
|
|
161
|
+
if (!row)
|
|
162
|
+
continue;
|
|
163
|
+
const tab = row.indexOf("\t");
|
|
164
|
+
if (tab < 1)
|
|
165
|
+
return false;
|
|
166
|
+
const header = row.slice(0, tab).match(/^100(?:644|755) blob ([0-9a-f]{40,64})$/i);
|
|
167
|
+
const path = row.slice(tab + 1);
|
|
168
|
+
if (path !== ".gitattributes" && !path.endsWith("/.gitattributes"))
|
|
169
|
+
continue;
|
|
170
|
+
if (!header)
|
|
171
|
+
return false;
|
|
172
|
+
const content = readBlob(header[1]);
|
|
173
|
+
if (content === null || !hunchAttributesAreSafe(content))
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=overlaySafety.js.map
|
package/dist/core/paths.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
6
|
/** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
|
|
@@ -27,7 +27,18 @@ export function hunchPaths(root) {
|
|
|
27
27
|
* PRIVATE overlay store (HUNCH_PRIVATE_DIR), which lives in a separate repo the
|
|
28
28
|
* user controls rather than under the current repo's `.hunch/`. */
|
|
29
29
|
export function hunchPathsForDir(hunchDir) {
|
|
30
|
-
const
|
|
30
|
+
const lexical = resolve(hunchDir);
|
|
31
|
+
// An explicitly configured PRIVATE overlay may intentionally be a
|
|
32
|
+
// final-component symlink to a distinct physical repository. Resolve that
|
|
33
|
+
// user-selected root before handing it to JsonStore; public hunchPaths()
|
|
34
|
+
// deliberately does not do this, so a committed public `.hunch` symlink and
|
|
35
|
+
// every kind/record symlink remain fail-closed.
|
|
36
|
+
let hunch = lexical;
|
|
37
|
+
try {
|
|
38
|
+
if (statSync(lexical).isDirectory())
|
|
39
|
+
hunch = realpathSync(lexical);
|
|
40
|
+
}
|
|
41
|
+
catch { /* missing overlay root is created at the lexical location */ }
|
|
31
42
|
return {
|
|
32
43
|
root: dirname(hunch),
|
|
33
44
|
hunch,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
/** Automatic repository scans are a convenience boundary, not a license to
|
|
4
|
+
* materialize arbitrarily large tracked blobs in memory. Eight MiB is well
|
|
5
|
+
* above ordinary source-file sizes while keeping one malicious/generated file
|
|
6
|
+
* from becoming an unbounded descriptor read. */
|
|
7
|
+
export const MAX_REPO_SOURCE_FILE_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
/** Build a no-follow reader for automatic repository scanners. A Git-tracked
|
|
9
|
+
* symlink still appears in `git ls-files`; following it can copy host data into
|
|
10
|
+
* generated memory or reports. This reader accepts only one unchanged regular
|
|
11
|
+
* file beneath the canonical repository root and reads through the descriptor
|
|
12
|
+
* whose identity was checked. */
|
|
13
|
+
export function createRepoFileBufferReader(root, options = {}) {
|
|
14
|
+
const lexicalRoot = resolve(root);
|
|
15
|
+
const canonicalRoot = realpathSync(lexicalRoot);
|
|
16
|
+
const maxBytes = options.maxBytes ?? MAX_REPO_SOURCE_FILE_BYTES;
|
|
17
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
18
|
+
throw new RangeError("maxBytes must be a non-negative safe integer");
|
|
19
|
+
}
|
|
20
|
+
return (file) => {
|
|
21
|
+
let descriptor;
|
|
22
|
+
try {
|
|
23
|
+
const target = isAbsolute(file) ? resolve(file) : resolve(lexicalRoot, file);
|
|
24
|
+
const lexicalRelative = relative(lexicalRoot, target);
|
|
25
|
+
if (!lexicalRelative || lexicalRelative === ".." || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative))
|
|
26
|
+
return null;
|
|
27
|
+
const before = lstatSync(target);
|
|
28
|
+
if (!before.isFile() || before.isSymbolicLink())
|
|
29
|
+
return null;
|
|
30
|
+
const canonicalTarget = realpathSync(target);
|
|
31
|
+
const canonicalRelative = relative(canonicalRoot, canonicalTarget);
|
|
32
|
+
if (!canonicalRelative || canonicalRelative === ".." || canonicalRelative.startsWith(`..${sep}`) || isAbsolute(canonicalRelative)
|
|
33
|
+
|| canonicalTarget !== resolve(canonicalRoot, lexicalRelative)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
descriptor = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
37
|
+
const opened = fstatSync(descriptor);
|
|
38
|
+
const after = lstatSync(target);
|
|
39
|
+
if (!opened.isFile() || !after.isFile() || after.isSymbolicLink()
|
|
40
|
+
|| opened.size > maxBytes
|
|
41
|
+
|| opened.dev !== before.dev || opened.ino !== before.ino
|
|
42
|
+
|| after.dev !== opened.dev || after.ino !== opened.ino
|
|
43
|
+
|| realpathSync(target) !== canonicalTarget) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// Read exactly the size that passed fstat. readFileSync(fd) reads to EOF,
|
|
47
|
+
// so an in-place grow after the check could otherwise defeat the ceiling.
|
|
48
|
+
const bytes = Buffer.allocUnsafe(opened.size);
|
|
49
|
+
let offset = 0;
|
|
50
|
+
while (offset < bytes.length) {
|
|
51
|
+
const read = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
52
|
+
if (read === 0)
|
|
53
|
+
break;
|
|
54
|
+
offset += read;
|
|
55
|
+
}
|
|
56
|
+
return bytes.subarray(0, offset);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
if (descriptor !== undefined)
|
|
63
|
+
closeSync(descriptor);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Text facade for parsers and legacy callers. Security-sensitive identities
|
|
68
|
+
* should hash the raw bytes returned by createRepoFileBufferReader first, since
|
|
69
|
+
* distinct invalid UTF-8 sequences can decode to the same replacement text. */
|
|
70
|
+
export function createRepoFileReader(root, options = {}) {
|
|
71
|
+
const read = createRepoFileBufferReader(root, options);
|
|
72
|
+
return (file) => read(file)?.toString("utf8") ?? null;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=safeRepoFile.js.map
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* <!--, ;) so a matching STRING literal in code isn't mistaken for intent. (Line-based,
|
|
8
8
|
* so a tagged line that is itself a string literal can still false-positive — advisory.)
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { trackedFiles } from "./git.js";
|
|
13
13
|
import { toPosixTarget } from "../core/paths.js";
|
|
14
|
+
import { createRepoFileReader } from "../core/safeRepoFile.js";
|
|
14
15
|
const EXTS = [
|
|
15
16
|
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
|
|
16
17
|
".py", ".go", ".rb", ".java", ".rs", ".php", ".cs", ".kt", ".swift", ".scala",
|
|
@@ -42,7 +43,7 @@ function sourceFiles(root) {
|
|
|
42
43
|
if (!SKIP.has(e.name))
|
|
43
44
|
walk(join(dir, e.name), r, depth + 1);
|
|
44
45
|
}
|
|
45
|
-
else if (EXTS.some((x) => e.name.endsWith(x))) {
|
|
46
|
+
else if (e.isFile() && EXTS.some((x) => e.name.endsWith(x))) {
|
|
46
47
|
out.push(r);
|
|
47
48
|
}
|
|
48
49
|
}
|
|
@@ -52,14 +53,11 @@ function sourceFiles(root) {
|
|
|
52
53
|
}
|
|
53
54
|
export function extractInlineIntent(root) {
|
|
54
55
|
const out = [];
|
|
56
|
+
const readSourceFile = createRepoFileReader(root);
|
|
55
57
|
for (const rel of sourceFiles(root)) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
content = readFileSync(join(root, rel), "utf8");
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
58
|
+
const content = readSourceFile(rel);
|
|
59
|
+
if (content === null)
|
|
61
60
|
continue;
|
|
62
|
-
}
|
|
63
61
|
if (!content.includes("hunch-"))
|
|
64
62
|
continue; // cheap skip before the per-line scan
|
|
65
63
|
const file = toPosixTarget(rel);
|