@smeltjs/core 0.2.0 → 0.3.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/README.md +3 -3
- package/dist/apply.d.ts.map +1 -1
- package/dist/apply.js.map +1 -1
- package/dist/cache/prefix.d.ts.map +1 -1
- package/dist/cache/prefix.js.map +1 -1
- package/dist/cli/args.d.ts +16 -70
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +18 -338
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +55 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/hooks.d.ts +33 -38
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +140 -485
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts +30 -15
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +167 -38
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +2 -2
- package/dist/cli/report.js.map +1 -1
- package/dist/cli/run.d.ts +25 -88
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +32 -341
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/shell.d.ts +106 -0
- package/dist/cli/shell.d.ts.map +1 -0
- package/dist/cli/shell.js +100 -0
- package/dist/cli/shell.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +141 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -0
- package/dist/cli/subcommands/flags.js +208 -0
- package/dist/cli/subcommands/flags.js.map +1 -0
- package/dist/cli/subcommands/hooks.d.ts +18 -0
- package/dist/cli/subcommands/hooks.d.ts.map +1 -0
- package/dist/cli/subcommands/hooks.js +64 -0
- package/dist/cli/subcommands/hooks.js.map +1 -0
- package/dist/cli/subcommands/init.d.ts +19 -0
- package/dist/cli/subcommands/init.d.ts.map +1 -0
- package/dist/cli/subcommands/init.js +44 -0
- package/dist/cli/subcommands/init.js.map +1 -0
- package/dist/cli/subcommands/map.d.ts +75 -0
- package/dist/cli/subcommands/map.d.ts.map +1 -0
- package/dist/cli/subcommands/map.js +132 -0
- package/dist/cli/subcommands/map.js.map +1 -0
- package/dist/cli/subcommands/registry.d.ts +80 -0
- package/dist/cli/subcommands/registry.d.ts.map +1 -0
- package/dist/cli/subcommands/registry.js +138 -0
- package/dist/cli/subcommands/registry.js.map +1 -0
- package/dist/cli/subcommands/retrieve.d.ts +74 -0
- package/dist/cli/subcommands/retrieve.d.ts.map +1 -0
- package/dist/cli/subcommands/retrieve.js +84 -0
- package/dist/cli/subcommands/retrieve.js.map +1 -0
- package/dist/cli/subcommands/smelt.d.ts +107 -0
- package/dist/cli/subcommands/smelt.d.ts.map +1 -0
- package/dist/cli/subcommands/smelt.js +304 -0
- package/dist/cli/subcommands/smelt.js.map +1 -0
- package/dist/cli/subcommands/stats.d.ts +43 -0
- package/dist/cli/subcommands/stats.d.ts.map +1 -0
- package/dist/cli/subcommands/stats.js +62 -0
- package/dist/cli/subcommands/stats.js.map +1 -0
- package/dist/cli/subcommands/subcommand.d.ts +113 -0
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -0
- package/dist/cli/subcommands/subcommand.js +3 -0
- package/dist/cli/subcommands/subcommand.js.map +1 -0
- package/dist/cli/usage.d.ts +3 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +95 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js.map +1 -1
- package/dist/errors.d.ts +21 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +33 -1
- package/dist/errors.js.map +1 -1
- package/dist/harness/aider.d.ts +10 -0
- package/dist/harness/aider.d.ts.map +1 -0
- package/dist/harness/aider.js +21 -0
- package/dist/harness/aider.js.map +1 -0
- package/dist/harness/claude-code.d.ts +3 -0
- package/dist/harness/claude-code.d.ts.map +1 -0
- package/dist/harness/claude-code.js +68 -0
- package/dist/harness/claude-code.js.map +1 -0
- package/dist/harness/cline.d.ts +3 -0
- package/dist/harness/cline.d.ts.map +1 -0
- package/dist/harness/cline.js +54 -0
- package/dist/harness/cline.js.map +1 -0
- package/dist/harness/codex.d.ts +3 -0
- package/dist/harness/codex.d.ts.map +1 -0
- package/dist/harness/codex.js +91 -0
- package/dist/harness/codex.js.map +1 -0
- package/dist/harness/cursor.d.ts +3 -0
- package/dist/harness/cursor.d.ts.map +1 -0
- package/dist/harness/cursor.js +54 -0
- package/dist/harness/cursor.js.map +1 -0
- package/dist/harness/gemini.d.ts +3 -0
- package/dist/harness/gemini.d.ts.map +1 -0
- package/dist/harness/gemini.js +59 -0
- package/dist/harness/gemini.js.map +1 -0
- package/dist/harness/grok.d.ts +3 -0
- package/dist/harness/grok.d.ts.map +1 -0
- package/dist/harness/grok.js +46 -0
- package/dist/harness/grok.js.map +1 -0
- package/dist/harness/hermes.d.ts +3 -0
- package/dist/harness/hermes.d.ts.map +1 -0
- package/dist/harness/hermes.js +71 -0
- package/dist/harness/hermes.js.map +1 -0
- package/dist/harness/kilocode.d.ts +3 -0
- package/dist/harness/kilocode.d.ts.map +1 -0
- package/dist/harness/kilocode.js +35 -0
- package/dist/harness/kilocode.js.map +1 -0
- package/dist/harness/opencode.d.ts +3 -0
- package/dist/harness/opencode.d.ts.map +1 -0
- package/dist/harness/opencode.js +97 -0
- package/dist/harness/opencode.js.map +1 -0
- package/dist/harness/paths.d.ts +17 -0
- package/dist/harness/paths.d.ts.map +1 -0
- package/dist/harness/paths.js +45 -0
- package/dist/harness/paths.js.map +1 -0
- package/dist/harness/profile.d.ts +184 -0
- package/dist/harness/profile.d.ts.map +1 -0
- package/dist/harness/profile.js +26 -0
- package/dist/harness/profile.js.map +1 -0
- package/dist/harness/registry.d.ts +51 -0
- package/dist/harness/registry.d.ts.map +1 -0
- package/dist/harness/registry.js +87 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/snippet.d.ts +19 -0
- package/dist/harness/snippet.d.ts.map +1 -0
- package/dist/harness/snippet.js +41 -0
- package/dist/harness/snippet.js.map +1 -0
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js.map +1 -1
- package/dist/hooks/guard-core.d.ts +14 -14
- package/dist/hooks/guard-core.d.ts.map +1 -1
- package/dist/hooks/guard-core.js +15 -37
- package/dist/hooks/guard-core.js.map +1 -1
- package/dist/hooks/shim.d.ts +95 -4
- package/dist/hooks/shim.d.ts.map +1 -1
- package/dist/hooks/shim.js +87 -2
- package/dist/hooks/shim.js.map +1 -1
- package/dist/hooks/shims/claude-code.d.ts +5 -18
- package/dist/hooks/shims/claude-code.d.ts.map +1 -1
- package/dist/hooks/shims/claude-code.js +9 -56
- package/dist/hooks/shims/claude-code.js.map +1 -1
- package/dist/hooks/shims/cline.d.ts +5 -12
- package/dist/hooks/shims/cline.d.ts.map +1 -1
- package/dist/hooks/shims/cline.js +9 -34
- package/dist/hooks/shims/cline.js.map +1 -1
- package/dist/hooks/shims/codex.d.ts +5 -18
- package/dist/hooks/shims/codex.d.ts.map +1 -1
- package/dist/hooks/shims/codex.js +9 -51
- package/dist/hooks/shims/codex.js.map +1 -1
- package/dist/hooks/shims/cursor.d.ts +5 -14
- package/dist/hooks/shims/cursor.d.ts.map +1 -1
- package/dist/hooks/shims/cursor.js +9 -42
- package/dist/hooks/shims/cursor.js.map +1 -1
- package/dist/hooks/shims/gemini.d.ts +5 -18
- package/dist/hooks/shims/gemini.d.ts.map +1 -1
- package/dist/hooks/shims/gemini.js +9 -48
- package/dist/hooks/shims/gemini.js.map +1 -1
- package/dist/hooks/shims/grok.d.ts +5 -13
- package/dist/hooks/shims/grok.d.ts.map +1 -1
- package/dist/hooks/shims/grok.js +9 -32
- package/dist/hooks/shims/grok.js.map +1 -1
- package/dist/hooks/shims/hermes.d.ts +5 -17
- package/dist/hooks/shims/hermes.d.ts.map +1 -1
- package/dist/hooks/shims/hermes.js +9 -45
- package/dist/hooks/shims/hermes.js.map +1 -1
- package/dist/index.d.ts +33 -76
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -67
- package/dist/index.js.map +1 -1
- package/dist/lang/bash.d.ts.map +1 -1
- package/dist/lang/bash.js.map +1 -1
- package/dist/lang/c.d.ts.map +1 -1
- package/dist/lang/c.js.map +1 -1
- package/dist/lang/c_sharp.d.ts.map +1 -1
- package/dist/lang/c_sharp.js.map +1 -1
- package/dist/lang/cpp.d.ts.map +1 -1
- package/dist/lang/cpp.js.map +1 -1
- package/dist/lang/go.d.ts.map +1 -1
- package/dist/lang/go.js.map +1 -1
- package/dist/lang/java.d.ts.map +1 -1
- package/dist/lang/java.js.map +1 -1
- package/dist/lang/javascript.d.ts.map +1 -1
- package/dist/lang/javascript.js.map +1 -1
- package/dist/lang/kotlin.d.ts.map +1 -1
- package/dist/lang/kotlin.js.map +1 -1
- package/dist/lang/php.d.ts.map +1 -1
- package/dist/lang/php.js.map +1 -1
- package/dist/lang/profile.d.ts.map +1 -1
- package/dist/lang/profile.js.map +1 -1
- package/dist/lang/python.d.ts.map +1 -1
- package/dist/lang/python.js.map +1 -1
- package/dist/lang/registry.d.ts.map +1 -1
- package/dist/lang/registry.js.map +1 -1
- package/dist/lang/ruby.d.ts.map +1 -1
- package/dist/lang/ruby.js.map +1 -1
- package/dist/lang/rust.d.ts.map +1 -1
- package/dist/lang/rust.js.map +1 -1
- package/dist/lang/swift.d.ts.map +1 -1
- package/dist/lang/swift.js.map +1 -1
- package/dist/lang/tsx.d.ts.map +1 -1
- package/dist/lang/tsx.js.map +1 -1
- package/dist/lang/typescript.d.ts.map +1 -1
- package/dist/lang/typescript.js.map +1 -1
- package/dist/net/policy.d.ts +18 -1
- package/dist/net/policy.d.ts.map +1 -1
- package/dist/net/policy.js +6 -4
- package/dist/net/policy.js.map +1 -1
- package/dist/ops/index.d.ts +39 -0
- package/dist/ops/index.d.ts.map +1 -0
- package/dist/ops/index.js +37 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/ops/inputs.d.ts +170 -0
- package/dist/ops/inputs.d.ts.map +1 -0
- package/dist/ops/inputs.js +142 -0
- package/dist/ops/inputs.js.map +1 -0
- package/dist/ops/verbs.d.ts +136 -0
- package/dist/ops/verbs.d.ts.map +1 -0
- package/dist/ops/verbs.js +76 -0
- package/dist/ops/verbs.js.map +1 -0
- package/dist/plan/auto.d.ts +69 -0
- package/dist/plan/auto.d.ts.map +1 -0
- package/dist/plan/auto.js +70 -0
- package/dist/plan/auto.js.map +1 -0
- package/dist/plan/budget.d.ts +21 -0
- package/dist/plan/budget.d.ts.map +1 -0
- package/dist/plan/budget.js +26 -0
- package/dist/plan/budget.js.map +1 -0
- package/dist/plan/grammar.d.ts +14 -0
- package/dist/plan/grammar.d.ts.map +1 -1
- package/dist/plan/grammar.js +47 -6
- package/dist/plan/grammar.js.map +1 -1
- package/dist/plan/lexical.d.ts.map +1 -1
- package/dist/plan/lexical.js +1 -7
- package/dist/plan/lexical.js.map +1 -1
- package/dist/plan/planners.d.ts +35 -2
- package/dist/plan/planners.d.ts.map +1 -1
- package/dist/plan/planners.js +35 -2
- package/dist/plan/planners.js.map +1 -1
- package/dist/plan/structural.d.ts +19 -5
- package/dist/plan/structural.d.ts.map +1 -1
- package/dist/plan/structural.js +180 -33
- package/dist/plan/structural.js.map +1 -1
- package/dist/repomap/cache.d.ts +48 -0
- package/dist/repomap/cache.d.ts.map +1 -1
- package/dist/repomap/cache.js +112 -13
- package/dist/repomap/cache.js.map +1 -1
- package/dist/repomap/io.d.ts +24 -0
- package/dist/repomap/io.d.ts.map +1 -0
- package/dist/repomap/io.js +34 -0
- package/dist/repomap/io.js.map +1 -0
- package/dist/repomap/map.d.ts +69 -6
- package/dist/repomap/map.d.ts.map +1 -1
- package/dist/repomap/map.js +104 -22
- package/dist/repomap/map.js.map +1 -1
- package/dist/repomap/rank.d.ts +26 -2
- package/dist/repomap/rank.d.ts.map +1 -1
- package/dist/repomap/rank.js +20 -0
- package/dist/repomap/rank.js.map +1 -1
- package/dist/repomap/reader.d.ts +73 -0
- package/dist/repomap/reader.d.ts.map +1 -0
- package/dist/repomap/reader.js +30 -0
- package/dist/repomap/reader.js.map +1 -0
- package/dist/repomap/tags.d.ts.map +1 -1
- package/dist/repomap/tags.js.map +1 -1
- package/dist/retrieve.d.ts +8 -0
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +12 -1
- package/dist/retrieve.js.map +1 -1
- package/dist/smelter.d.ts +79 -0
- package/dist/smelter.d.ts.map +1 -0
- package/dist/smelter.js +65 -0
- package/dist/smelter.js.map +1 -0
- package/dist/stages.d.ts.map +1 -1
- package/dist/stages.js +2 -2
- package/dist/stages.js.map +1 -1
- package/dist/stats.d.ts +2 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +2 -1
- package/dist/stats.js.map +1 -1
- package/dist/store-dir.d.ts +43 -4
- package/dist/store-dir.d.ts.map +1 -1
- package/dist/store-dir.js +48 -5
- package/dist/store-dir.js.map +1 -1
- package/dist/store.d.ts +6 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +6 -0
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +35 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +11 -11
- package/dist/cli/resolve.d.ts +0 -103
- package/dist/cli/resolve.d.ts.map +0 -1
- package/dist/cli/resolve.js +0 -106
- package/dist/cli/resolve.js.map +0 -1
package/dist/repomap/cache.d.ts
CHANGED
|
@@ -18,6 +18,32 @@ import type { LanguageId } from '../types.ts';
|
|
|
18
18
|
* source, and the caller's result carries a warning naming the file. Trusting a
|
|
19
19
|
* damaged cache would silently drop symbols from the map, which is this project's
|
|
20
20
|
* signature failure mode.
|
|
21
|
+
*
|
|
22
|
+
* **It is bounded, and the bound is a sweep.** The key is a content hash, so an edit
|
|
23
|
+
* does not replace an entry — it mints a new one and orphans the old, which is
|
|
24
|
+
* invisible and permanent: every pre-edit version of every file the caller ever mapped
|
|
25
|
+
* stays on disk for as long as the directory does. A long session therefore grew this
|
|
26
|
+
* cache without limit while never reading most of it. So {@link TagsCache.sweep}
|
|
27
|
+
* deletes every entry the build it just finished did not use, leaving exactly the tags
|
|
28
|
+
* of the tree as it now stands. The policy is stated as a bound anyone can check: **an
|
|
29
|
+
* entry survives a build only if that build used it**, so the cache is at most one
|
|
30
|
+
* entry per mappable file in the tree — plus whatever a crash left mid-write, which
|
|
31
|
+
* no sweep can safely reclaim; see {@link ENTRY_FILE}.
|
|
32
|
+
*
|
|
33
|
+
* Why that is safe, and the rule any other policy would also have to meet: **a miss
|
|
34
|
+
* can only make a map slower, never wrong.** A missing entry is re-extracted from the
|
|
35
|
+
* file's own bytes, and a present entry is only ever served for the exact content that
|
|
36
|
+
* hashed to its key — so sweeping too much costs a re-parse and sweeping too little
|
|
37
|
+
* costs disk, and neither can change a single symbol in the emitted map. The same rule
|
|
38
|
+
* is why the sweep never throws: it runs after the map is finished, so a cache
|
|
39
|
+
* directory that will not list postpones the bound to the next build rather than
|
|
40
|
+
* turning a computed map into no map at all.
|
|
41
|
+
*
|
|
42
|
+
* The cost is paid by a caller who points *one* cache directory at *several* trees:
|
|
43
|
+
* each build sweeps the others' entries, and every build then re-parses. That is a
|
|
44
|
+
* slower map, not a wrong one, and it is measured rather than hidden — the sweep's
|
|
45
|
+
* count rides back in `RepoMap.cache.pruned` and is printed in `smelt map`'s report.
|
|
46
|
+
* One cache directory per tree is the shape this is tuned for.
|
|
21
47
|
*/
|
|
22
48
|
/** The format name every cache entry carries. */
|
|
23
49
|
export declare const TAGS_CACHE_FORMAT = "smelt-repomap-tags";
|
|
@@ -49,5 +75,27 @@ export declare class TagsCache {
|
|
|
49
75
|
* source, so losing one to a crash costs a re-parse, not a broken promise.
|
|
50
76
|
*/
|
|
51
77
|
write(key: string, tags: FileTags): void;
|
|
78
|
+
/**
|
|
79
|
+
* Delete every entry that is not in `live` — the bound on this cache, described in
|
|
80
|
+
* full in the module comment above. `live` is the set of keys the build that just
|
|
81
|
+
* finished actually used, so what survives is exactly the tags of the tree as it
|
|
82
|
+
* now stands, and the superseded pre-edit entries a content-hash key would otherwise
|
|
83
|
+
* accumulate forever are gone.
|
|
84
|
+
*
|
|
85
|
+
* Returns how many entries were removed, so the caller can report a measured number
|
|
86
|
+
* rather than a claim.
|
|
87
|
+
*
|
|
88
|
+
* **The whole sweep is best effort, listing included.** A directory that will not
|
|
89
|
+
* list — an unreadable cache directory, or one an external cleaner removed between
|
|
90
|
+
* the last write and this call — reports `0` pruned and leaves the map alone. It
|
|
91
|
+
* runs after the tree has been walked, ranked and rendered, so a throw here would
|
|
92
|
+
* destroy a finished map over housekeeping smelt only wanted to do: no map at all
|
|
93
|
+
* instead of a slower one, which is the trade this cache is forbidden to make.
|
|
94
|
+
* Deleting one entry is best effort for the same reason. Nothing is lost by
|
|
95
|
+
* skipping a sweep — the entries are offered again on the next build, and until
|
|
96
|
+
* then nothing reads them, because nothing will ever look up a key no file hashes
|
|
97
|
+
* to.
|
|
98
|
+
*/
|
|
99
|
+
sweep(live: ReadonlySet<string>): number;
|
|
52
100
|
}
|
|
53
101
|
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/repomap/cache.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAiB,QAAQ,EAAgB,MAAM,WAAW,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;;;;;;;;;;GAkBG;AAEH,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED,8CAA8C;AAC9C,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;AAE/D,qBAAa,SAAS;;IAGpB,qFAAqF;IACrF,YAAY,GAAG,EAAE,MAAM,EAGtB;IAED;;;;OAIG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAqBjC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAWvC;CAeF"}
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/repomap/cache.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAiB,QAAQ,EAAgB,MAAM,WAAW,CAAC;AACvE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,uBAAuB,CAAC;AAEtD,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED,8CAA8C;AAC9C,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;AAkB/D,qBAAa,SAAS;;IAGpB,qFAAqF;IACrF,YAAY,GAAG,EAAE,MAAM,EAKtB;IAED;;;;OAIG;IACH,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CA2BjC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAavC;IAED;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,KAAK,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,CAqBvC;CAeF","sourcesContent":["import {\n mkdirSync,\n readdirSync,\n readFileSync,\n renameSync,\n unlinkSync,\n writeFileSync,\n} from 'node:fs';\nimport { join } from 'node:path';\nimport process from 'node:process';\n\nimport { contentHash } from '../hash.ts';\nimport { fsCall } from './io.ts';\nimport type { DefinitionTag, FileTags, ReferenceTag } from './tags.ts';\nimport type { LanguageId } from '../types.ts';\n\n/**\n * The repo map's disk cache — per-file tags, keyed by content hash, JSON on disk.\n *\n * Modelled on Aider's repo-map tags cache (https://aider.chat/docs/repomap.html),\n * with one deliberate substitution: Aider persists through SQLite; this repo ships\n * zero new runtime dependencies, so entries are plain JSON files under a directory\n * **the caller explicitly hands in**. smelt never writes outside a store or cache it\n * was handed — no default location, no home-directory guessing.\n *\n * The key is a content hash over the tag-format version, the language, and the file's\n * exact text. That is the whole invalidation story: edit a file and its key changes,\n * so the stale entry is simply never looked up again. Nothing needs a timestamp.\n *\n * A corrupt entry — unparseable JSON, or JSON of the wrong shape — is **discarded\n * loudly, never trusted**: the entry file is deleted, the tags are re-extracted from\n * source, and the caller's result carries a warning naming the file. Trusting a\n * damaged cache would silently drop symbols from the map, which is this project's\n * signature failure mode.\n *\n * **It is bounded, and the bound is a sweep.** The key is a content hash, so an edit\n * does not replace an entry — it mints a new one and orphans the old, which is\n * invisible and permanent: every pre-edit version of every file the caller ever mapped\n * stays on disk for as long as the directory does. A long session therefore grew this\n * cache without limit while never reading most of it. So {@link TagsCache.sweep}\n * deletes every entry the build it just finished did not use, leaving exactly the tags\n * of the tree as it now stands. The policy is stated as a bound anyone can check: **an\n * entry survives a build only if that build used it**, so the cache is at most one\n * entry per mappable file in the tree — plus whatever a crash left mid-write, which\n * no sweep can safely reclaim; see {@link ENTRY_FILE}.\n *\n * Why that is safe, and the rule any other policy would also have to meet: **a miss\n * can only make a map slower, never wrong.** A missing entry is re-extracted from the\n * file's own bytes, and a present entry is only ever served for the exact content that\n * hashed to its key — so sweeping too much costs a re-parse and sweeping too little\n * costs disk, and neither can change a single symbol in the emitted map. The same rule\n * is why the sweep never throws: it runs after the map is finished, so a cache\n * directory that will not list postpones the bound to the next build rather than\n * turning a computed map into no map at all.\n *\n * The cost is paid by a caller who points *one* cache directory at *several* trees:\n * each build sweeps the others' entries, and every build then re-parses. That is a\n * slower map, not a wrong one, and it is measured rather than hidden — the sweep's\n * count rides back in `RepoMap.cache.pruned` and is printed in `smelt map`'s report.\n * One cache directory per tree is the shape this is tuned for.\n */\n\n/** The format name every cache entry carries. */\nexport const TAGS_CACHE_FORMAT = 'smelt-repomap-tags';\n\n/** Bump this when the tag shape changes: old entries then miss instead of misleading. */\nexport const TAGS_CACHE_VERSION = 1;\n\n/**\n * The cache key for one file's tags. The version and language are part of the hashed\n * material, so a format bump or a re-detected language can never resurrect an entry\n * extracted under different rules.\n */\nexport function tagsCacheKey(language: LanguageId, content: string): string {\n return contentHash(`${TAGS_CACHE_FORMAT}/${String(TAGS_CACHE_VERSION)}\\0${language}\\0${content}`);\n}\n\n/** What `read()` reports about one lookup. */\nexport type TagsCacheLookup = FileTags | 'corrupt' | undefined;\n\n/**\n * An entry file's name, and the key inside it. Only a name of exactly this shape is\n * ever swept: a temp file from a write in flight (`<key>.json.tmp-<pid>`) does not\n * match, so a sweep cannot delete the file another process is at that moment renaming\n * into place.\n *\n * The price of that exclusion, stated rather than hidden: the bound above is over\n * *entries*, and a process killed between the `writeFileSync` and the `renameSync` in\n * {@link TagsCache.write} leaves a temp file no later sweep reclaims. Reclaiming one\n * safely needs a liveness test this cache cannot make — a pid can be reused, and an\n * mtime cutoff is a number smelt would have invented — and the alternative, deleting\n * a temp file a live writer is about to rename, fails that writer's build. So a\n * crash's leftovers are counted as the known cost of never racing a concurrent write.\n */\nconst ENTRY_FILE = /^([0-9a-f]+)\\.json$/;\n\nexport class TagsCache {\n readonly #entriesDir: string;\n\n /** `dir` is the directory the caller handed in; entries live under `<dir>/tags/`. */\n constructor(dir: string) {\n this.#entriesDir = join(dir, 'tags');\n fsCall('create the tags cache directory', this.#entriesDir, () => {\n mkdirSync(this.#entriesDir, { recursive: true });\n });\n }\n\n /**\n * The cached tags under `key`, `undefined` on a miss, `'corrupt'` when an entry\n * existed but could not be trusted — in which case it has already been deleted, so\n * the corruption is reported exactly once and never re-read.\n */\n read(key: string): TagsCacheLookup {\n const path = this.#entryPath(key);\n const raw = fsCall('read the cache entry', path, (): string | undefined => {\n try {\n return readFileSync(path, 'utf8');\n } catch (error) {\n // Structural, not `NodeJS.ErrnoException`: this type reaches the shipped\n // declarations, and an ambient namespace there breaks a consumer compiling\n // with `skipLibCheck: false`.\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n });\n if (raw === undefined) return undefined;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n this.#discard(key);\n return 'corrupt';\n }\n const tags = validateEntry(parsed);\n if (tags === undefined) {\n this.#discard(key);\n return 'corrupt';\n }\n return tags;\n }\n\n /**\n * Persist one file's tags. Written to a temp name and renamed into place, so a\n * concurrent reader never sees a half-written entry; both writers of the same key\n * are writing identical bytes (the key covers the content), so last-rename-wins is\n * harmless. No fsync: unlike the elision store, every entry here is derivable from\n * source, so losing one to a crash costs a re-parse, not a broken promise.\n */\n write(key: string, tags: FileTags): void {\n const body = `${JSON.stringify({\n format: TAGS_CACHE_FORMAT,\n version: TAGS_CACHE_VERSION,\n defs: tags.defs,\n refs: tags.refs,\n })}\\n`;\n const target = this.#entryPath(key);\n const temp = `${target}.tmp-${String(process.pid)}`;\n fsCall('write the cache entry', target, () => {\n writeFileSync(temp, body, 'utf8');\n renameSync(temp, target);\n });\n }\n\n /**\n * Delete every entry that is not in `live` — the bound on this cache, described in\n * full in the module comment above. `live` is the set of keys the build that just\n * finished actually used, so what survives is exactly the tags of the tree as it\n * now stands, and the superseded pre-edit entries a content-hash key would otherwise\n * accumulate forever are gone.\n *\n * Returns how many entries were removed, so the caller can report a measured number\n * rather than a claim.\n *\n * **The whole sweep is best effort, listing included.** A directory that will not\n * list — an unreadable cache directory, or one an external cleaner removed between\n * the last write and this call — reports `0` pruned and leaves the map alone. It\n * runs after the tree has been walked, ranked and rendered, so a throw here would\n * destroy a finished map over housekeeping smelt only wanted to do: no map at all\n * instead of a slower one, which is the trade this cache is forbidden to make.\n * Deleting one entry is best effort for the same reason. Nothing is lost by\n * skipping a sweep — the entries are offered again on the next build, and until\n * then nothing reads them, because nothing will ever look up a key no file hashes\n * to.\n */\n sweep(live: ReadonlySet<string>): number {\n let names: string[];\n try {\n names = readdirSync(this.#entriesDir);\n } catch {\n return 0;\n }\n let pruned = 0;\n for (const name of names.toSorted()) {\n const key = ENTRY_FILE.exec(name)?.[1];\n if (key === undefined || live.has(key)) continue;\n try {\n unlinkSync(join(this.#entriesDir, name));\n pruned += 1;\n } catch {\n // Already gone, or undeletable. Either way it is one stale entry that will be\n // offered for sweeping again on the next build; nothing reads it meanwhile,\n // because nothing will ever look up a key no file hashes to.\n }\n }\n return pruned;\n }\n\n #entryPath(key: string): string {\n return join(this.#entriesDir, `${key}.json`);\n }\n\n /** Delete a corrupt entry so it can never be re-read as truth. Best effort. */\n #discard(key: string): void {\n try {\n unlinkSync(this.#entryPath(key));\n } catch {\n // Already gone, or undeletable — either way it will be overwritten by the\n // rewrite that follows every discard.\n }\n }\n}\n\n/** The parsed entry as `FileTags`, or `undefined` when its shape cannot be trusted. */\nfunction validateEntry(parsed: unknown): FileTags | undefined {\n if (typeof parsed !== 'object' || parsed === null) return undefined;\n const entry = parsed as { format?: unknown; version?: unknown; defs?: unknown; refs?: unknown };\n if (entry.format !== TAGS_CACHE_FORMAT || entry.version !== TAGS_CACHE_VERSION) return undefined;\n if (!Array.isArray(entry.defs) || !Array.isArray(entry.refs)) return undefined;\n\n const defs: DefinitionTag[] = [];\n for (const item of entry.defs as unknown[]) {\n const def = item as { name?: unknown; kind?: unknown; line?: unknown };\n if (typeof def.name !== 'string' || def.name === '') return undefined;\n if (typeof def.kind !== 'string' || def.kind === '') return undefined;\n if (typeof def.line !== 'number' || !Number.isInteger(def.line) || def.line < 1) {\n return undefined;\n }\n defs.push({ name: def.name, kind: def.kind, line: def.line });\n }\n const refs: ReferenceTag[] = [];\n for (const item of entry.refs as unknown[]) {\n const ref = item as { name?: unknown; count?: unknown };\n if (typeof ref.name !== 'string' || ref.name === '') return undefined;\n if (typeof ref.count !== 'number' || !Number.isInteger(ref.count) || ref.count < 1) {\n return undefined;\n }\n refs.push({ name: ref.name, count: ref.count });\n }\n return { defs, refs };\n}\n"]}
|
package/dist/repomap/cache.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { contentHash } from '../hash.js';
|
|
5
|
+
import { fsCall } from './io.js';
|
|
5
6
|
/**
|
|
6
7
|
* The repo map's disk cache — per-file tags, keyed by content hash, JSON on disk.
|
|
7
8
|
*
|
|
@@ -20,6 +21,32 @@ import { contentHash } from '../hash.js';
|
|
|
20
21
|
* source, and the caller's result carries a warning naming the file. Trusting a
|
|
21
22
|
* damaged cache would silently drop symbols from the map, which is this project's
|
|
22
23
|
* signature failure mode.
|
|
24
|
+
*
|
|
25
|
+
* **It is bounded, and the bound is a sweep.** The key is a content hash, so an edit
|
|
26
|
+
* does not replace an entry — it mints a new one and orphans the old, which is
|
|
27
|
+
* invisible and permanent: every pre-edit version of every file the caller ever mapped
|
|
28
|
+
* stays on disk for as long as the directory does. A long session therefore grew this
|
|
29
|
+
* cache without limit while never reading most of it. So {@link TagsCache.sweep}
|
|
30
|
+
* deletes every entry the build it just finished did not use, leaving exactly the tags
|
|
31
|
+
* of the tree as it now stands. The policy is stated as a bound anyone can check: **an
|
|
32
|
+
* entry survives a build only if that build used it**, so the cache is at most one
|
|
33
|
+
* entry per mappable file in the tree — plus whatever a crash left mid-write, which
|
|
34
|
+
* no sweep can safely reclaim; see {@link ENTRY_FILE}.
|
|
35
|
+
*
|
|
36
|
+
* Why that is safe, and the rule any other policy would also have to meet: **a miss
|
|
37
|
+
* can only make a map slower, never wrong.** A missing entry is re-extracted from the
|
|
38
|
+
* file's own bytes, and a present entry is only ever served for the exact content that
|
|
39
|
+
* hashed to its key — so sweeping too much costs a re-parse and sweeping too little
|
|
40
|
+
* costs disk, and neither can change a single symbol in the emitted map. The same rule
|
|
41
|
+
* is why the sweep never throws: it runs after the map is finished, so a cache
|
|
42
|
+
* directory that will not list postpones the bound to the next build rather than
|
|
43
|
+
* turning a computed map into no map at all.
|
|
44
|
+
*
|
|
45
|
+
* The cost is paid by a caller who points *one* cache directory at *several* trees:
|
|
46
|
+
* each build sweeps the others' entries, and every build then re-parses. That is a
|
|
47
|
+
* slower map, not a wrong one, and it is measured rather than hidden — the sweep's
|
|
48
|
+
* count rides back in `RepoMap.cache.pruned` and is printed in `smelt map`'s report.
|
|
49
|
+
* One cache directory per tree is the shape this is tuned for.
|
|
23
50
|
*/
|
|
24
51
|
/** The format name every cache entry carries. */
|
|
25
52
|
export const TAGS_CACHE_FORMAT = 'smelt-repomap-tags';
|
|
@@ -33,12 +60,29 @@ export const TAGS_CACHE_VERSION = 1;
|
|
|
33
60
|
export function tagsCacheKey(language, content) {
|
|
34
61
|
return contentHash(`${TAGS_CACHE_FORMAT}/${String(TAGS_CACHE_VERSION)}\0${language}\0${content}`);
|
|
35
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* An entry file's name, and the key inside it. Only a name of exactly this shape is
|
|
65
|
+
* ever swept: a temp file from a write in flight (`<key>.json.tmp-<pid>`) does not
|
|
66
|
+
* match, so a sweep cannot delete the file another process is at that moment renaming
|
|
67
|
+
* into place.
|
|
68
|
+
*
|
|
69
|
+
* The price of that exclusion, stated rather than hidden: the bound above is over
|
|
70
|
+
* *entries*, and a process killed between the `writeFileSync` and the `renameSync` in
|
|
71
|
+
* {@link TagsCache.write} leaves a temp file no later sweep reclaims. Reclaiming one
|
|
72
|
+
* safely needs a liveness test this cache cannot make — a pid can be reused, and an
|
|
73
|
+
* mtime cutoff is a number smelt would have invented — and the alternative, deleting
|
|
74
|
+
* a temp file a live writer is about to rename, fails that writer's build. So a
|
|
75
|
+
* crash's leftovers are counted as the known cost of never racing a concurrent write.
|
|
76
|
+
*/
|
|
77
|
+
const ENTRY_FILE = /^([0-9a-f]+)\.json$/;
|
|
36
78
|
export class TagsCache {
|
|
37
79
|
#entriesDir;
|
|
38
80
|
/** `dir` is the directory the caller handed in; entries live under `<dir>/tags/`. */
|
|
39
81
|
constructor(dir) {
|
|
40
82
|
this.#entriesDir = join(dir, 'tags');
|
|
41
|
-
|
|
83
|
+
fsCall('create the tags cache directory', this.#entriesDir, () => {
|
|
84
|
+
mkdirSync(this.#entriesDir, { recursive: true });
|
|
85
|
+
});
|
|
42
86
|
}
|
|
43
87
|
/**
|
|
44
88
|
* The cached tags under `key`, `undefined` on a miss, `'corrupt'` when an entry
|
|
@@ -46,15 +90,22 @@ export class TagsCache {
|
|
|
46
90
|
* the corruption is reported exactly once and never re-read.
|
|
47
91
|
*/
|
|
48
92
|
read(key) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
93
|
+
const path = this.#entryPath(key);
|
|
94
|
+
const raw = fsCall('read the cache entry', path, () => {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(path, 'utf8');
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Structural, not `NodeJS.ErrnoException`: this type reaches the shipped
|
|
100
|
+
// declarations, and an ambient namespace there breaks a consumer compiling
|
|
101
|
+
// with `skipLibCheck: false`.
|
|
102
|
+
if (error.code === 'ENOENT')
|
|
103
|
+
return undefined;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (raw === undefined)
|
|
108
|
+
return undefined;
|
|
58
109
|
let parsed;
|
|
59
110
|
try {
|
|
60
111
|
parsed = JSON.parse(raw);
|
|
@@ -86,8 +137,56 @@ export class TagsCache {
|
|
|
86
137
|
})}\n`;
|
|
87
138
|
const target = this.#entryPath(key);
|
|
88
139
|
const temp = `${target}.tmp-${String(process.pid)}`;
|
|
89
|
-
|
|
90
|
-
|
|
140
|
+
fsCall('write the cache entry', target, () => {
|
|
141
|
+
writeFileSync(temp, body, 'utf8');
|
|
142
|
+
renameSync(temp, target);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Delete every entry that is not in `live` — the bound on this cache, described in
|
|
147
|
+
* full in the module comment above. `live` is the set of keys the build that just
|
|
148
|
+
* finished actually used, so what survives is exactly the tags of the tree as it
|
|
149
|
+
* now stands, and the superseded pre-edit entries a content-hash key would otherwise
|
|
150
|
+
* accumulate forever are gone.
|
|
151
|
+
*
|
|
152
|
+
* Returns how many entries were removed, so the caller can report a measured number
|
|
153
|
+
* rather than a claim.
|
|
154
|
+
*
|
|
155
|
+
* **The whole sweep is best effort, listing included.** A directory that will not
|
|
156
|
+
* list — an unreadable cache directory, or one an external cleaner removed between
|
|
157
|
+
* the last write and this call — reports `0` pruned and leaves the map alone. It
|
|
158
|
+
* runs after the tree has been walked, ranked and rendered, so a throw here would
|
|
159
|
+
* destroy a finished map over housekeeping smelt only wanted to do: no map at all
|
|
160
|
+
* instead of a slower one, which is the trade this cache is forbidden to make.
|
|
161
|
+
* Deleting one entry is best effort for the same reason. Nothing is lost by
|
|
162
|
+
* skipping a sweep — the entries are offered again on the next build, and until
|
|
163
|
+
* then nothing reads them, because nothing will ever look up a key no file hashes
|
|
164
|
+
* to.
|
|
165
|
+
*/
|
|
166
|
+
sweep(live) {
|
|
167
|
+
let names;
|
|
168
|
+
try {
|
|
169
|
+
names = readdirSync(this.#entriesDir);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
let pruned = 0;
|
|
175
|
+
for (const name of names.toSorted()) {
|
|
176
|
+
const key = ENTRY_FILE.exec(name)?.[1];
|
|
177
|
+
if (key === undefined || live.has(key))
|
|
178
|
+
continue;
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(join(this.#entriesDir, name));
|
|
181
|
+
pruned += 1;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Already gone, or undeletable. Either way it is one stale entry that will be
|
|
185
|
+
// offered for sweeping again on the next build; nothing reads it meanwhile,
|
|
186
|
+
// because nothing will ever look up a key no file hashes to.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return pruned;
|
|
91
190
|
}
|
|
92
191
|
#entryPath(key) {
|
|
93
192
|
return join(this.#entriesDir, `${key}.json`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/repomap/cache.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cache.js","sourceRoot":"","sources":["../../src/repomap/cache.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n mkdirSync,\n readdirSync,\n readFileSync,\n renameSync,\n unlinkSync,\n writeFileSync,\n} from 'node:fs';\nimport { join } from 'node:path';\nimport process from 'node:process';\n\nimport { contentHash } from '../hash.ts';\nimport { fsCall } from './io.ts';\nimport type { DefinitionTag, FileTags, ReferenceTag } from './tags.ts';\nimport type { LanguageId } from '../types.ts';\n\n/**\n * The repo map's disk cache — per-file tags, keyed by content hash, JSON on disk.\n *\n * Modelled on Aider's repo-map tags cache (https://aider.chat/docs/repomap.html),\n * with one deliberate substitution: Aider persists through SQLite; this repo ships\n * zero new runtime dependencies, so entries are plain JSON files under a directory\n * **the caller explicitly hands in**. smelt never writes outside a store or cache it\n * was handed — no default location, no home-directory guessing.\n *\n * The key is a content hash over the tag-format version, the language, and the file's\n * exact text. That is the whole invalidation story: edit a file and its key changes,\n * so the stale entry is simply never looked up again. Nothing needs a timestamp.\n *\n * A corrupt entry — unparseable JSON, or JSON of the wrong shape — is **discarded\n * loudly, never trusted**: the entry file is deleted, the tags are re-extracted from\n * source, and the caller's result carries a warning naming the file. Trusting a\n * damaged cache would silently drop symbols from the map, which is this project's\n * signature failure mode.\n *\n * **It is bounded, and the bound is a sweep.** The key is a content hash, so an edit\n * does not replace an entry — it mints a new one and orphans the old, which is\n * invisible and permanent: every pre-edit version of every file the caller ever mapped\n * stays on disk for as long as the directory does. A long session therefore grew this\n * cache without limit while never reading most of it. So {@link TagsCache.sweep}\n * deletes every entry the build it just finished did not use, leaving exactly the tags\n * of the tree as it now stands. The policy is stated as a bound anyone can check: **an\n * entry survives a build only if that build used it**, so the cache is at most one\n * entry per mappable file in the tree — plus whatever a crash left mid-write, which\n * no sweep can safely reclaim; see {@link ENTRY_FILE}.\n *\n * Why that is safe, and the rule any other policy would also have to meet: **a miss\n * can only make a map slower, never wrong.** A missing entry is re-extracted from the\n * file's own bytes, and a present entry is only ever served for the exact content that\n * hashed to its key — so sweeping too much costs a re-parse and sweeping too little\n * costs disk, and neither can change a single symbol in the emitted map. The same rule\n * is why the sweep never throws: it runs after the map is finished, so a cache\n * directory that will not list postpones the bound to the next build rather than\n * turning a computed map into no map at all.\n *\n * The cost is paid by a caller who points *one* cache directory at *several* trees:\n * each build sweeps the others' entries, and every build then re-parses. That is a\n * slower map, not a wrong one, and it is measured rather than hidden — the sweep's\n * count rides back in `RepoMap.cache.pruned` and is printed in `smelt map`'s report.\n * One cache directory per tree is the shape this is tuned for.\n */\n\n/** The format name every cache entry carries. */\nexport const TAGS_CACHE_FORMAT = 'smelt-repomap-tags';\n\n/** Bump this when the tag shape changes: old entries then miss instead of misleading. */\nexport const TAGS_CACHE_VERSION = 1;\n\n/**\n * The cache key for one file's tags. The version and language are part of the hashed\n * material, so a format bump or a re-detected language can never resurrect an entry\n * extracted under different rules.\n */\nexport function tagsCacheKey(language: LanguageId, content: string): string {\n return contentHash(`${TAGS_CACHE_FORMAT}/${String(TAGS_CACHE_VERSION)}\\0${language}\\0${content}`);\n}\n\n/** What `read()` reports about one lookup. */\nexport type TagsCacheLookup = FileTags | 'corrupt' | undefined;\n\n/**\n * An entry file's name, and the key inside it. Only a name of exactly this shape is\n * ever swept: a temp file from a write in flight (`<key>.json.tmp-<pid>`) does not\n * match, so a sweep cannot delete the file another process is at that moment renaming\n * into place.\n *\n * The price of that exclusion, stated rather than hidden: the bound above is over\n * *entries*, and a process killed between the `writeFileSync` and the `renameSync` in\n * {@link TagsCache.write} leaves a temp file no later sweep reclaims. Reclaiming one\n * safely needs a liveness test this cache cannot make — a pid can be reused, and an\n * mtime cutoff is a number smelt would have invented — and the alternative, deleting\n * a temp file a live writer is about to rename, fails that writer's build. So a\n * crash's leftovers are counted as the known cost of never racing a concurrent write.\n */\nconst ENTRY_FILE = /^([0-9a-f]+)\\.json$/;\n\nexport class TagsCache {\n readonly #entriesDir: string;\n\n /** `dir` is the directory the caller handed in; entries live under `<dir>/tags/`. */\n constructor(dir: string) {\n this.#entriesDir = join(dir, 'tags');\n fsCall('create the tags cache directory', this.#entriesDir, () => {\n mkdirSync(this.#entriesDir, { recursive: true });\n });\n }\n\n /**\n * The cached tags under `key`, `undefined` on a miss, `'corrupt'` when an entry\n * existed but could not be trusted — in which case it has already been deleted, so\n * the corruption is reported exactly once and never re-read.\n */\n read(key: string): TagsCacheLookup {\n const path = this.#entryPath(key);\n const raw = fsCall('read the cache entry', path, (): string | undefined => {\n try {\n return readFileSync(path, 'utf8');\n } catch (error) {\n // Structural, not `NodeJS.ErrnoException`: this type reaches the shipped\n // declarations, and an ambient namespace there breaks a consumer compiling\n // with `skipLibCheck: false`.\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n });\n if (raw === undefined) return undefined;\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n this.#discard(key);\n return 'corrupt';\n }\n const tags = validateEntry(parsed);\n if (tags === undefined) {\n this.#discard(key);\n return 'corrupt';\n }\n return tags;\n }\n\n /**\n * Persist one file's tags. Written to a temp name and renamed into place, so a\n * concurrent reader never sees a half-written entry; both writers of the same key\n * are writing identical bytes (the key covers the content), so last-rename-wins is\n * harmless. No fsync: unlike the elision store, every entry here is derivable from\n * source, so losing one to a crash costs a re-parse, not a broken promise.\n */\n write(key: string, tags: FileTags): void {\n const body = `${JSON.stringify({\n format: TAGS_CACHE_FORMAT,\n version: TAGS_CACHE_VERSION,\n defs: tags.defs,\n refs: tags.refs,\n })}\\n`;\n const target = this.#entryPath(key);\n const temp = `${target}.tmp-${String(process.pid)}`;\n fsCall('write the cache entry', target, () => {\n writeFileSync(temp, body, 'utf8');\n renameSync(temp, target);\n });\n }\n\n /**\n * Delete every entry that is not in `live` — the bound on this cache, described in\n * full in the module comment above. `live` is the set of keys the build that just\n * finished actually used, so what survives is exactly the tags of the tree as it\n * now stands, and the superseded pre-edit entries a content-hash key would otherwise\n * accumulate forever are gone.\n *\n * Returns how many entries were removed, so the caller can report a measured number\n * rather than a claim.\n *\n * **The whole sweep is best effort, listing included.** A directory that will not\n * list — an unreadable cache directory, or one an external cleaner removed between\n * the last write and this call — reports `0` pruned and leaves the map alone. It\n * runs after the tree has been walked, ranked and rendered, so a throw here would\n * destroy a finished map over housekeeping smelt only wanted to do: no map at all\n * instead of a slower one, which is the trade this cache is forbidden to make.\n * Deleting one entry is best effort for the same reason. Nothing is lost by\n * skipping a sweep — the entries are offered again on the next build, and until\n * then nothing reads them, because nothing will ever look up a key no file hashes\n * to.\n */\n sweep(live: ReadonlySet<string>): number {\n let names: string[];\n try {\n names = readdirSync(this.#entriesDir);\n } catch {\n return 0;\n }\n let pruned = 0;\n for (const name of names.toSorted()) {\n const key = ENTRY_FILE.exec(name)?.[1];\n if (key === undefined || live.has(key)) continue;\n try {\n unlinkSync(join(this.#entriesDir, name));\n pruned += 1;\n } catch {\n // Already gone, or undeletable. Either way it is one stale entry that will be\n // offered for sweeping again on the next build; nothing reads it meanwhile,\n // because nothing will ever look up a key no file hashes to.\n }\n }\n return pruned;\n }\n\n #entryPath(key: string): string {\n return join(this.#entriesDir, `${key}.json`);\n }\n\n /** Delete a corrupt entry so it can never be re-read as truth. Best effort. */\n #discard(key: string): void {\n try {\n unlinkSync(this.#entryPath(key));\n } catch {\n // Already gone, or undeletable — either way it will be overwritten by the\n // rewrite that follows every discard.\n }\n }\n}\n\n/** The parsed entry as `FileTags`, or `undefined` when its shape cannot be trusted. */\nfunction validateEntry(parsed: unknown): FileTags | undefined {\n if (typeof parsed !== 'object' || parsed === null) return undefined;\n const entry = parsed as { format?: unknown; version?: unknown; defs?: unknown; refs?: unknown };\n if (entry.format !== TAGS_CACHE_FORMAT || entry.version !== TAGS_CACHE_VERSION) return undefined;\n if (!Array.isArray(entry.defs) || !Array.isArray(entry.refs)) return undefined;\n\n const defs: DefinitionTag[] = [];\n for (const item of entry.defs as unknown[]) {\n const def = item as { name?: unknown; kind?: unknown; line?: unknown };\n if (typeof def.name !== 'string' || def.name === '') return undefined;\n if (typeof def.kind !== 'string' || def.kind === '') return undefined;\n if (typeof def.line !== 'number' || !Number.isInteger(def.line) || def.line < 1) {\n return undefined;\n }\n defs.push({ name: def.name, kind: def.kind, line: def.line });\n }\n const refs: ReferenceTag[] = [];\n for (const item of entry.refs as unknown[]) {\n const ref = item as { name?: unknown; count?: unknown };\n if (typeof ref.name !== 'string' || ref.name === '') return undefined;\n if (typeof ref.count !== 'number' || !Number.isInteger(ref.count) || ref.count < 1) {\n return undefined;\n }\n refs.push({ name: ref.name, count: ref.count });\n }\n return { defs, refs };\n}\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the repo map touches a filesystem call that can throw.
|
|
3
|
+
*
|
|
4
|
+
* The consumer contract makes exactly one promise about errors — every error smelt
|
|
5
|
+
* throws is an `instanceof SmeltError` — and the repo map is the module most able to
|
|
6
|
+
* break it, because it walks a whole tree the caller named. `buildRepoMap({ root:
|
|
7
|
+
* '/nonexistent' })` threw the raw Node `ENOENT` from `readdirSync`: not a
|
|
8
|
+
* `SmeltError`, past the documented `catch`, with the path only in a stack trace. The
|
|
9
|
+
* same escape was one `EACCES` away in the tags cache, in `stat`, and in `read`.
|
|
10
|
+
*
|
|
11
|
+
* So every such call is written as `fsCall(operation, path, () => …)`. It adds no
|
|
12
|
+
* behaviour — the call still fails, at the same moment, for the same reason — it only
|
|
13
|
+
* puts the failure inside the contract and names the path in the message.
|
|
14
|
+
*
|
|
15
|
+
* A {@link SmeltError} passes through untouched. A caller's own {@link RepoReader} may
|
|
16
|
+
* refuse in smelt's own currency (a store that will not serve a path, say); that
|
|
17
|
+
* refusal is already inside the contract, and rewrapping it would bury a sentence its
|
|
18
|
+
* author wrote deliberately under a generic one.
|
|
19
|
+
*
|
|
20
|
+
* `operation` is a verb phrase that reads as `could not <operation> "<path>"` —
|
|
21
|
+
* `list the directory`, `read the file`, `write the cache entry`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function fsCall<T>(operation: string, path: string, run: () => T): T;
|
|
24
|
+
//# sourceMappingURL=io.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"io.d.ts","sourceRoot":"","sources":["../../src/repomap/io.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,MAAM,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAO1E","sourcesContent":["import { RepoMapIoError, SmeltError } from '../errors.ts';\n\n/**\n * The one place the repo map touches a filesystem call that can throw.\n *\n * The consumer contract makes exactly one promise about errors — every error smelt\n * throws is an `instanceof SmeltError` — and the repo map is the module most able to\n * break it, because it walks a whole tree the caller named. `buildRepoMap({ root:\n * '/nonexistent' })` threw the raw Node `ENOENT` from `readdirSync`: not a\n * `SmeltError`, past the documented `catch`, with the path only in a stack trace. The\n * same escape was one `EACCES` away in the tags cache, in `stat`, and in `read`.\n *\n * So every such call is written as `fsCall(operation, path, () => …)`. It adds no\n * behaviour — the call still fails, at the same moment, for the same reason — it only\n * puts the failure inside the contract and names the path in the message.\n *\n * A {@link SmeltError} passes through untouched. A caller's own {@link RepoReader} may\n * refuse in smelt's own currency (a store that will not serve a path, say); that\n * refusal is already inside the contract, and rewrapping it would bury a sentence its\n * author wrote deliberately under a generic one.\n *\n * `operation` is a verb phrase that reads as `could not <operation> \"<path>\"` —\n * `list the directory`, `read the file`, `write the cache entry`.\n */\nexport function fsCall<T>(operation: string, path: string, run: () => T): T {\n try {\n return run();\n } catch (error) {\n if (error instanceof SmeltError) throw error;\n throw new RepoMapIoError(operation, path, error);\n }\n}\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { RepoMapIoError, SmeltError } from '../errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* The one place the repo map touches a filesystem call that can throw.
|
|
4
|
+
*
|
|
5
|
+
* The consumer contract makes exactly one promise about errors — every error smelt
|
|
6
|
+
* throws is an `instanceof SmeltError` — and the repo map is the module most able to
|
|
7
|
+
* break it, because it walks a whole tree the caller named. `buildRepoMap({ root:
|
|
8
|
+
* '/nonexistent' })` threw the raw Node `ENOENT` from `readdirSync`: not a
|
|
9
|
+
* `SmeltError`, past the documented `catch`, with the path only in a stack trace. The
|
|
10
|
+
* same escape was one `EACCES` away in the tags cache, in `stat`, and in `read`.
|
|
11
|
+
*
|
|
12
|
+
* So every such call is written as `fsCall(operation, path, () => …)`. It adds no
|
|
13
|
+
* behaviour — the call still fails, at the same moment, for the same reason — it only
|
|
14
|
+
* puts the failure inside the contract and names the path in the message.
|
|
15
|
+
*
|
|
16
|
+
* A {@link SmeltError} passes through untouched. A caller's own {@link RepoReader} may
|
|
17
|
+
* refuse in smelt's own currency (a store that will not serve a path, say); that
|
|
18
|
+
* refusal is already inside the contract, and rewrapping it would bury a sentence its
|
|
19
|
+
* author wrote deliberately under a generic one.
|
|
20
|
+
*
|
|
21
|
+
* `operation` is a verb phrase that reads as `could not <operation> "<path>"` —
|
|
22
|
+
* `list the directory`, `read the file`, `write the cache entry`.
|
|
23
|
+
*/
|
|
24
|
+
export function fsCall(operation, path, run) {
|
|
25
|
+
try {
|
|
26
|
+
return run();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof SmeltError)
|
|
30
|
+
throw error;
|
|
31
|
+
throw new RepoMapIoError(operation, path, error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=io.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"io.js","sourceRoot":"","sources":["../../src/repomap/io.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,MAAM,CAAI,SAAiB,EAAE,IAAY,EAAE,GAAY;IACrE,IAAI,CAAC;QACH,OAAO,GAAG,EAAE,CAAC;IACf,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,UAAU;YAAE,MAAM,KAAK,CAAC;QAC7C,MAAM,IAAI,cAAc,CAAC,SAAS,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IACnD,CAAC;AACH,CAAC","sourcesContent":["import { RepoMapIoError, SmeltError } from '../errors.ts';\n\n/**\n * The one place the repo map touches a filesystem call that can throw.\n *\n * The consumer contract makes exactly one promise about errors — every error smelt\n * throws is an `instanceof SmeltError` — and the repo map is the module most able to\n * break it, because it walks a whole tree the caller named. `buildRepoMap({ root:\n * '/nonexistent' })` threw the raw Node `ENOENT` from `readdirSync`: not a\n * `SmeltError`, past the documented `catch`, with the path only in a stack trace. The\n * same escape was one `EACCES` away in the tags cache, in `stat`, and in `read`.\n *\n * So every such call is written as `fsCall(operation, path, () => …)`. It adds no\n * behaviour — the call still fails, at the same moment, for the same reason — it only\n * puts the failure inside the contract and names the path in the message.\n *\n * A {@link SmeltError} passes through untouched. A caller's own {@link RepoReader} may\n * refuse in smelt's own currency (a store that will not serve a path, say); that\n * refusal is already inside the contract, and rewrapping it would bury a sentence its\n * author wrote deliberately under a generic one.\n *\n * `operation` is a verb phrase that reads as `could not <operation> \"<path>\"` —\n * `list the directory`, `read the file`, `write the cache entry`.\n */\nexport function fsCall<T>(operation: string, path: string, run: () => T): T {\n try {\n return run();\n } catch (error) {\n if (error instanceof SmeltError) throw error;\n throw new RepoMapIoError(operation, path, error);\n }\n}\n"]}
|
package/dist/repomap/map.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { RepoReader } from './reader.ts';
|
|
1
2
|
/**
|
|
2
|
-
* The repo-map builder —
|
|
3
|
+
* The repo-map builder — the cross-file shape.
|
|
3
4
|
*
|
|
4
5
|
* **Modelled on Aider's repo-map, and credited as such.** The whole design is prior
|
|
5
6
|
* art: Paul Gauthier's Aider extracts tree-sitter definition/reference tags per file,
|
|
@@ -11,6 +12,9 @@
|
|
|
11
12
|
*
|
|
12
13
|
* - **Local files only** (Law 1). The caller names the root; symlinks are never
|
|
13
14
|
* followed, so the walk cannot leave it; binary files are skipped; no network.
|
|
15
|
+
* The whole tree is read through one read-only seam, {@link RepoReader} — the
|
|
16
|
+
* default is `node:fs` and the interface has no writer, so the only bytes this
|
|
17
|
+
* module can write are the tags cache the caller asked for by name.
|
|
14
18
|
* - **Every inclusion is explainable** (Law 2, applied to inclusion rather than
|
|
15
19
|
* elision): each symbol in the map carries a rule id and a sentence stating its
|
|
16
20
|
* definition site and the measured reference counts that ranked it.
|
|
@@ -18,8 +22,32 @@
|
|
|
18
22
|
* path, name, and line. Two runs over the same tree are byte-identical.
|
|
19
23
|
* - **The budget is bytes**, same contract as the planners, and it is respected by
|
|
20
24
|
* construction: symbols are added in rank order until the next line would not fit.
|
|
21
|
-
* - **The cache lives only in a directory the caller explicitly hands in**,
|
|
22
|
-
*
|
|
25
|
+
* - **The cache lives only in a directory the caller explicitly hands in**, a corrupt
|
|
26
|
+
* entry is discarded loudly — a warning in the result — never trusted, and it is
|
|
27
|
+
* bounded: each build sweeps the entries it did not use, so the superseded
|
|
28
|
+
* pre-edit version of every file does not accumulate forever. The sweep is
|
|
29
|
+
* housekeeping and never fatal: it runs after the map is computed, so a cache it
|
|
30
|
+
* cannot tidy costs a re-parse next time, never this map. See `cache.ts`.
|
|
31
|
+
* - **Every failure is a `SmeltError`**: the consumer contract's one promise about
|
|
32
|
+
* errors holds here too, so a missing root or an unreadable file arrives as
|
|
33
|
+
* {@link RepoMapIoError} naming the path, not as a raw Node `ENOENT`. Every
|
|
34
|
+
* filesystem call in this module goes through `fsCall` in `io.ts`.
|
|
35
|
+
*
|
|
36
|
+
* **What the ranking can and cannot resolve.** A reference binds to a definition **by
|
|
37
|
+
* bare identifier** — the tags carry names, not resolved symbols — so every definition
|
|
38
|
+
* of a name receives every reference to that name, wherever either lives. Two files
|
|
39
|
+
* that both define `run` share one another's inbound references and therefore rank
|
|
40
|
+
* alike; two overloads of one name each count the whole traffic to it, so a name is
|
|
41
|
+
* counted once per definition of it; an identifier that happens to collide with an
|
|
42
|
+
* unrelated one somewhere else in the tree lends it rank. This is Aider's design, not
|
|
43
|
+
* a defect introduced here: resolving properly means per-language import and scope
|
|
44
|
+
* resolution — a type checker per language — and the map is a *ranking heuristic* for
|
|
45
|
+
* deciding what a human or a model should look at first, never a symbol resolver.
|
|
46
|
+
* What it must not do is *claim* a resolution it did not perform, and it does not: the
|
|
47
|
+
* receipt on each entry says what was actually measured — `refsIn` is the number of
|
|
48
|
+
* references to that **name** across the scanned tree — so a reader who knows this
|
|
49
|
+
* paragraph can read the numbers for exactly what they are. Anything that needs true
|
|
50
|
+
* binding (rename, call graph, dead-code detection) needs a different tool.
|
|
23
51
|
*
|
|
24
52
|
* **Deliberately NOT a `Planner`.** `buildRepoMap` returns a {@link RepoMap},
|
|
25
53
|
* not an `ElisionPlan`: nothing here is elided, nothing is stored under a hash, and
|
|
@@ -50,9 +78,26 @@ export declare const REPO_MAP_PATH_ONLY_RULE = "path-only";
|
|
|
50
78
|
/** Rule id for the warning left behind when a corrupt cache entry is discarded. */
|
|
51
79
|
export declare const REPO_MAP_CACHE_CORRUPT_RULE = "cache-entry-corrupt";
|
|
52
80
|
/**
|
|
53
|
-
* The ignore list used when the caller supplies none.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
81
|
+
* The ignore list used when the caller supplies none.
|
|
82
|
+
*
|
|
83
|
+
* Two kinds of directory are on it, for one reason: **including them makes the map
|
|
84
|
+
* wrong, not merely large.** `.git` is object storage and `node_modules` is other
|
|
85
|
+
* people's code — neither is the repository's own source. The rest are build outputs,
|
|
86
|
+
* and they are the sharper case: a built TypeScript repo carries `dist/foo.js` and
|
|
87
|
+
* `dist/foo.d.ts` beside `src/foo.ts`, so *every* symbol was ranked and rendered three
|
|
88
|
+
* times, the duplicates referenced each other, and the default map of the commonest
|
|
89
|
+
* repo shape in this ecosystem was a third source and two-thirds its own compiler
|
|
90
|
+
* output. The names here (`dist`, `build`, `out`, `coverage`) are the conventional
|
|
91
|
+
* output directories of the toolchains this map is most often pointed at; a repo that
|
|
92
|
+
* builds somewhere else passes its own list.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately still tiny, and deliberately **not** a `.gitignore` parser: an ignore
|
|
95
|
+
* list smelt cannot state in one sentence is one nobody can predict.
|
|
96
|
+
*
|
|
97
|
+
* A caller with opinions passes its own list, which *replaces* this one — it is not
|
|
98
|
+
* merged with it. Replacement is the documented contract because a merge means there
|
|
99
|
+
* is no way to say "map my `dist`, I meant it", and a default you cannot turn off is
|
|
100
|
+
* not a default.
|
|
56
101
|
*/
|
|
57
102
|
export declare const DEFAULT_REPO_IGNORE: readonly string[];
|
|
58
103
|
/** Why an entry is in the map — same two-register shape as {@link ElisionReason}. */
|
|
@@ -87,6 +132,14 @@ export interface RepoMapOptions {
|
|
|
87
132
|
* to disk — smelt never writes outside a store or cache it was explicitly handed.
|
|
88
133
|
*/
|
|
89
134
|
readonly cacheDir?: string;
|
|
135
|
+
/**
|
|
136
|
+
* The filesystem the map reads through. Defaults to {@link nodeFsReader} — plain
|
|
137
|
+
* `node:fs`, the calls this module used to make in-line — so callers that do not
|
|
138
|
+
* care never mention it. Hand in a {@link RepoReader} to map a tree that is not on
|
|
139
|
+
* this disk, or to watch, call by call, exactly what the walk touched. Read-only
|
|
140
|
+
* by construction: the interface has no writer.
|
|
141
|
+
*/
|
|
142
|
+
readonly reader?: RepoReader;
|
|
90
143
|
}
|
|
91
144
|
/** One symbol included in the rendered map, with the receipt for its inclusion. */
|
|
92
145
|
export interface RepoMapEntry {
|
|
@@ -123,6 +176,12 @@ export interface RepoMapCacheCounts {
|
|
|
123
176
|
readonly misses: number;
|
|
124
177
|
/** Corrupt entries deleted and re-extracted; each one also left a warning. */
|
|
125
178
|
readonly discarded: number;
|
|
179
|
+
/**
|
|
180
|
+
* Entries this build swept because it did not use them — the bound on the cache,
|
|
181
|
+
* made visible. A non-zero count on a repeat build over an unchanged tree means the
|
|
182
|
+
* cache directory is shared with another tree, which costs re-parses.
|
|
183
|
+
*/
|
|
184
|
+
readonly pruned: number;
|
|
126
185
|
}
|
|
127
186
|
export interface RepoMap {
|
|
128
187
|
readonly id: typeof REPO_MAP_ID;
|
|
@@ -154,6 +213,10 @@ export interface RepoMap {
|
|
|
154
213
|
* `test/guards/repo-map.test.ts`, not assumed.
|
|
155
214
|
*
|
|
156
215
|
* @throws {SmeltError} when `budgetBytes` is not a positive integer.
|
|
216
|
+
* @throws {RepoMapIoError} when a filesystem call fails — a root that is not there, a
|
|
217
|
+
* directory that will not list, a file that will not read. Never a raw Node error:
|
|
218
|
+
* the contract says every error smelt throws is a `SmeltError`, and a repo map that
|
|
219
|
+
* let `ENOENT` past would be the exception that made the promise worthless.
|
|
157
220
|
* @throws {GrammarUnavailableError} when a supported language's grammar cannot load —
|
|
158
221
|
* never a silent skip that would make the map quietly incomplete.
|
|
159
222
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../src/repomap/map.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../src/repomap/map.ts"],"names":[],"mappings":"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","sourcesContent":["import { join } from 'node:path';\n\nimport { detectLanguage } from '../detect.ts';\nimport { SmeltError } from '../errors.ts';\nimport type { LanguageId } from '../types.ts';\n\nimport { TagsCache, tagsCacheKey } from './cache.ts';\nimport { fsCall } from './io.ts';\nimport { rankDefinitions } from './rank.ts';\nimport type { FileTagsEntry, RankedDefinition } from './rank.ts';\nimport { nodeFsReader } from './reader.ts';\nimport type { RepoReader } from './reader.ts';\nimport { extractTags } from './tags.ts';\nimport type { FileTags } from './tags.ts';\n\n/**\n * The repo-map builder — the cross-file shape.\n *\n * **Modelled on Aider's repo-map, and credited as such.** The whole design is prior\n * art: Paul Gauthier's Aider extracts tree-sitter definition/reference tags per file,\n * runs PageRank over the cross-file reference graph, fits the ranked result to a\n * budget, and caches tags on disk keyed by content — see\n * https://aider.chat/docs/repomap.html and `aider/repomap.py` in\n * https://github.com/Aider-AI/aider. Nothing about the approach is this project's\n * invention; what this module adds is only smelt's own house rules:\n *\n * - **Local files only** (Law 1). The caller names the root; symlinks are never\n * followed, so the walk cannot leave it; binary files are skipped; no network.\n * The whole tree is read through one read-only seam, {@link RepoReader} — the\n * default is `node:fs` and the interface has no writer, so the only bytes this\n * module can write are the tags cache the caller asked for by name.\n * - **Every inclusion is explainable** (Law 2, applied to inclusion rather than\n * elision): each symbol in the map carries a rule id and a sentence stating its\n * definition site and the measured reference counts that ranked it.\n * - **Deterministic**: fixed PageRank constants, sorted walks, a total tie-break by\n * path, name, and line. Two runs over the same tree are byte-identical.\n * - **The budget is bytes**, same contract as the planners, and it is respected by\n * construction: symbols are added in rank order until the next line would not fit.\n * - **The cache lives only in a directory the caller explicitly hands in**, a corrupt\n * entry is discarded loudly — a warning in the result — never trusted, and it is\n * bounded: each build sweeps the entries it did not use, so the superseded\n * pre-edit version of every file does not accumulate forever. The sweep is\n * housekeeping and never fatal: it runs after the map is computed, so a cache it\n * cannot tidy costs a re-parse next time, never this map. See `cache.ts`.\n * - **Every failure is a `SmeltError`**: the consumer contract's one promise about\n * errors holds here too, so a missing root or an unreadable file arrives as\n * {@link RepoMapIoError} naming the path, not as a raw Node `ENOENT`. Every\n * filesystem call in this module goes through `fsCall` in `io.ts`.\n *\n * **What the ranking can and cannot resolve.** A reference binds to a definition **by\n * bare identifier** — the tags carry names, not resolved symbols — so every definition\n * of a name receives every reference to that name, wherever either lives. Two files\n * that both define `run` share one another's inbound references and therefore rank\n * alike; two overloads of one name each count the whole traffic to it, so a name is\n * counted once per definition of it; an identifier that happens to collide with an\n * unrelated one somewhere else in the tree lends it rank. This is Aider's design, not\n * a defect introduced here: resolving properly means per-language import and scope\n * resolution — a type checker per language — and the map is a *ranking heuristic* for\n * deciding what a human or a model should look at first, never a symbol resolver.\n * What it must not do is *claim* a resolution it did not perform, and it does not: the\n * receipt on each entry says what was actually measured — `refsIn` is the number of\n * references to that **name** across the scanned tree — so a reader who knows this\n * paragraph can read the numbers for exactly what they are. Anything that needs true\n * binding (rename, call graph, dead-code detection) needs a different tool.\n *\n * **Deliberately NOT a `Planner`.** `buildRepoMap` returns a {@link RepoMap},\n * not an `ElisionPlan`: nothing here is elided, nothing is stored under a hash, and\n * there is no original to reconstruct — the map is a *summary built up under a\n * budget*, not a *removal to be reversed*. Forcing the Planner interface onto it\n * would claim Law 3 (reversibility) about output that has no bytes to give back,\n * which is exactly the kind of lie the interface exists to prevent. The CLI serves\n * it as its own subcommand (`smelt map`), never as a `--strategy`.\n */\n\n/** The id stamped on every map this module emits. */\nexport const REPO_MAP_ID = 'repomap/v1';\n\n/**\n * Rule id for a symbol something in the scanned tree references — its own file\n * included, so its rank may still be zero when every reference is same-file (only\n * cross-file references move rank).\n */\nexport const REPO_MAP_RANKED_RULE = 'ranked-definition';\n\n/** Rule id for a definition nothing in the scanned tree references. */\nexport const REPO_MAP_UNREFERENCED_RULE = 'unreferenced-definition';\n\n/**\n * Rule id for a definition promoted because it matches a caller-supplied focus term.\n * The rank stays the measured PageRank share — focus moves a symbol's *place in the\n * fill order*, never its numbers.\n */\nexport const REPO_MAP_FOCUS_RULE = 'focus-match';\n\n/** Rule id for a file listed by path because no definitions were extracted from it. */\nexport const REPO_MAP_PATH_ONLY_RULE = 'path-only';\n\n/** Rule id for the warning left behind when a corrupt cache entry is discarded. */\nexport const REPO_MAP_CACHE_CORRUPT_RULE = 'cache-entry-corrupt';\n\n/**\n * The ignore list used when the caller supplies none.\n *\n * Two kinds of directory are on it, for one reason: **including them makes the map\n * wrong, not merely large.** `.git` is object storage and `node_modules` is other\n * people's code — neither is the repository's own source. The rest are build outputs,\n * and they are the sharper case: a built TypeScript repo carries `dist/foo.js` and\n * `dist/foo.d.ts` beside `src/foo.ts`, so *every* symbol was ranked and rendered three\n * times, the duplicates referenced each other, and the default map of the commonest\n * repo shape in this ecosystem was a third source and two-thirds its own compiler\n * output. The names here (`dist`, `build`, `out`, `coverage`) are the conventional\n * output directories of the toolchains this map is most often pointed at; a repo that\n * builds somewhere else passes its own list.\n *\n * Deliberately still tiny, and deliberately **not** a `.gitignore` parser: an ignore\n * list smelt cannot state in one sentence is one nobody can predict.\n *\n * A caller with opinions passes its own list, which *replaces* this one — it is not\n * merged with it. Replacement is the documented contract because a merge means there\n * is no way to say \"map my `dist`, I meant it\", and a default you cannot turn off is\n * not a default.\n */\nexport const DEFAULT_REPO_IGNORE: readonly string[] = [\n '.git',\n 'node_modules',\n 'dist',\n 'build',\n 'out',\n 'coverage',\n];\n\n/** Why an entry is in the map — same two-register shape as {@link ElisionReason}. */\nexport interface RepoMapReason {\n /** Stable machine id, e.g. `'ranked-definition'`. */\n readonly rule: string;\n /** A sentence a human can read: definition site and the counts that ranked it. */\n readonly explanation: string;\n}\n\nexport interface RepoMapOptions {\n /** The repository root to read. Local files only; symlinks are never followed. */\n readonly root: string;\n /** Ceiling for the rendered map, in UTF-8 bytes. Respected, not aimed at. */\n readonly budgetBytes: number;\n /**\n * What the task is actually about — same meaning as a planner's focus. A\n * definition whose name or path contains a term (case-insensitive, like the\n * lexical planner's default) is promoted to the front of the fill order, so it\n * survives a tight budget. Promotion only: the measured rank and reference\n * counts are never altered, and each promoted entry says which term it matched.\n */\n readonly focus?: readonly string[];\n /**\n * Paths to skip, replacing {@link DEFAULT_REPO_IGNORE}. An entry containing `/` is\n * matched as a root-relative path prefix — a trailing slash counts, so `build/`\n * means the root-level `build` tree, never every `build` segment anywhere; a bare\n * name matches any path segment.\n */\n readonly ignore?: readonly string[];\n /**\n * Directory for the tags cache. **Only** when this is passed is anything written\n * to disk — smelt never writes outside a store or cache it was explicitly handed.\n */\n readonly cacheDir?: string;\n /**\n * The filesystem the map reads through. Defaults to {@link nodeFsReader} — plain\n * `node:fs`, the calls this module used to make in-line — so callers that do not\n * care never mention it. Hand in a {@link RepoReader} to map a tree that is not on\n * this disk, or to watch, call by call, exactly what the walk touched. Read-only\n * by construction: the interface has no writer.\n */\n readonly reader?: RepoReader;\n}\n\n/** One symbol included in the rendered map, with the receipt for its inclusion. */\nexport interface RepoMapEntry {\n readonly path: string;\n readonly name: string;\n readonly kind: string;\n /** 1-based line of the definition. */\n readonly line: number;\n /** PageRank share this definition attracted. `0` for unreferenced definitions. */\n readonly rank: number;\n /** Measured: total references to this name across the scanned tree. */\n readonly refsIn: number;\n /** Measured: distinct files holding those references. */\n readonly refsInFiles: number;\n /** Measured: references the defining file makes to names defined elsewhere. */\n readonly refsOut: number;\n readonly reason: RepoMapReason;\n}\n\n/** A file listed by path because no definitions were extracted from it. */\nexport interface RepoMapPathEntry {\n readonly path: string;\n readonly reason: RepoMapReason;\n}\n\n/** Something worth telling the caller that is not worth failing the build over. */\nexport interface RepoMapWarning {\n readonly rule: string;\n readonly explanation: string;\n}\n\n/** Measured cache activity for one build. Counts only — smelt claims no rates. */\nexport interface RepoMapCacheCounts {\n /** Lookups answered from disk. */\n readonly hits: number;\n /** Lookups that found nothing and re-extracted from source. */\n readonly misses: number;\n /** Corrupt entries deleted and re-extracted; each one also left a warning. */\n readonly discarded: number;\n /**\n * Entries this build swept because it did not use them — the bound on the cache,\n * made visible. A non-zero count on a repeat build over an unchanged tree means the\n * cache directory is shared with another tree, which costs re-parses.\n */\n readonly pruned: number;\n}\n\nexport interface RepoMap {\n readonly id: typeof REPO_MAP_ID;\n /** The rendered map. Always at most `budgetBytes` UTF-8 bytes. */\n readonly text: string;\n readonly outputBytes: number;\n readonly budgetBytes: number;\n /** The symbols that fit, in fill order (focus matches first, then rank). */\n readonly entries: readonly RepoMapEntry[];\n /** Path-only files that fit, after the symbols, in path order. */\n readonly pathOnly: readonly RepoMapPathEntry[];\n /** All ranked definitions found, before the budget cut anything. */\n readonly definitionsTotal: number;\n /** All path-only candidates found, before the budget cut anything. */\n readonly pathOnlyTotal: number;\n /** Regular files examined under the root, after the ignore list. */\n readonly filesScanned: number;\n /** Files skipped because their bytes contain a NUL — binary, not mappable. */\n readonly binarySkipped: number;\n readonly warnings: readonly RepoMapWarning[];\n /** Present only when the caller handed in a `cacheDir`. Never invented. */\n readonly cache?: RepoMapCacheCounts;\n}\n\n/**\n * Build a ranked symbol map of the repository under `options.root`.\n *\n * Reads local files only. Deterministic: two runs over the same tree, with or without\n * a warm cache, produce byte-identical maps — asserted by the guard in\n * `test/guards/repo-map.test.ts`, not assumed.\n *\n * @throws {SmeltError} when `budgetBytes` is not a positive integer.\n * @throws {RepoMapIoError} when a filesystem call fails — a root that is not there, a\n * directory that will not list, a file that will not read. Never a raw Node error:\n * the contract says every error smelt throws is a `SmeltError`, and a repo map that\n * let `ENOENT` past would be the exception that made the promise worthless.\n * @throws {GrammarUnavailableError} when a supported language's grammar cannot load —\n * never a silent skip that would make the map quietly incomplete.\n */\nexport async function buildRepoMap(options: RepoMapOptions): Promise<RepoMap> {\n const { root, budgetBytes } = options;\n if (!Number.isInteger(budgetBytes) || budgetBytes < 1) {\n throw new SmeltError(\n `smelt: budgetBytes must be a positive integer, got ${String(budgetBytes)}. ` +\n `There is no default budget — a budget smelt invented would silently decide ` +\n `how much of the map to throw away.`,\n );\n }\n const ignore = options.ignore ?? DEFAULT_REPO_IGNORE;\n const reader = options.reader ?? nodeFsReader();\n const cacheDir = options.cacheDir;\n const cache =\n cacheDir === undefined\n ? undefined\n : fsCall('open the tags cache directory', cacheDir, () => new TagsCache(cacheDir));\n const cacheCounts = { hits: 0, misses: 0, discarded: 0, pruned: 0 };\n // The keys this build used. Everything else under the cache directory is a\n // superseded entry — the pre-edit version of a file, or another tree's tags — and\n // is swept once the walk is done. See the bound in `cache.ts`.\n const liveKeys = new Set<string>();\n const warnings: RepoMapWarning[] = [];\n\n const files = scanFiles(reader, root, ignore);\n let binarySkipped = 0;\n const parsed: FileTagsEntry[] = [];\n const pathOnlyPaths: string[] = [];\n\n for (const rel of files) {\n const path = join(root, ...rel.split('/'));\n const bytes = fsCall('read the file', path, () => reader.read(path));\n if (bytes.includes(0)) {\n binarySkipped += 1;\n continue;\n }\n const language = detectLanguage(rel);\n if (language === 'unknown') {\n pathOnlyPaths.push(rel);\n continue;\n }\n // `RepoReader.read` promises `Uint8Array`, not `Buffer` (its own note says why),\n // and `ignoreBOM` keeps this byte-for-byte what `Buffer.toString('utf8')` did: a\n // leading U+FEFF stays in the text instead of being silently eaten.\n const text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes);\n const tags = await tagsFor(text, language, cache, cacheCounts, liveKeys, warnings, rel);\n if (tags.defs.length === 0) pathOnlyPaths.push(rel);\n if (tags.defs.length > 0 || tags.refs.length > 0) parsed.push({ path: rel, tags });\n }\n\n if (cache !== undefined) cacheCounts.pruned = cache.sweep(liveKeys);\n\n const ranked = rankDefinitions(parsed);\n const focus = (options.focus ?? []).filter((term) => term.length > 0);\n const ordered = orderWithFocus(ranked, focus);\n\n // Fit to the budget: ranked symbols first — focus matches promoted to the front,\n // each partition in rank order — then path-only files, in path order. Filling\n // stops at the first line that does not fit, so the included set is always a\n // prefix of that order — a map that skipped its #2 symbol to squeeze in its #9\n // would be lying about what mattered.\n const lines: string[] = [];\n let bytes = 0;\n const tryAppend = (line: string): boolean => {\n const lineBytes = Buffer.byteLength(line, 'utf8') + 1; // its trailing newline\n if (bytes + lineBytes > budgetBytes) return false;\n lines.push(line);\n bytes += lineBytes;\n return true;\n };\n\n const entries: RepoMapEntry[] = [];\n for (const { definition, focusTerm } of ordered) {\n if (!tryAppend(renderDefinition(definition))) break;\n entries.push(toEntry(definition, focusTerm));\n }\n const pathOnly: RepoMapPathEntry[] = [];\n if (entries.length === ordered.length) {\n for (const rel of pathOnlyPaths) {\n if (!tryAppend(`${rel} [path only]`)) break;\n pathOnly.push({\n path: rel,\n reason: {\n rule: REPO_MAP_PATH_ONLY_RULE,\n explanation: `no definitions extracted from ${rel}; listed by path so the file stays visible`,\n },\n });\n }\n }\n\n const text = lines.length === 0 ? '' : `${lines.join('\\n')}\\n`;\n return {\n id: REPO_MAP_ID,\n text,\n outputBytes: Buffer.byteLength(text, 'utf8'),\n budgetBytes,\n entries,\n pathOnly,\n definitionsTotal: ranked.length,\n pathOnlyTotal: pathOnlyPaths.length,\n filesScanned: files.length,\n binarySkipped,\n warnings,\n ...(cache === undefined ? {} : { cache: { ...cacheCounts } }),\n };\n}\n\n/** One file's tags — from the cache when possible, from the grammar when not. */\nasync function tagsFor(\n text: string,\n language: LanguageId,\n cache: TagsCache | undefined,\n counts: { hits: number; misses: number; discarded: number; pruned: number },\n liveKeys: Set<string>,\n warnings: RepoMapWarning[],\n rel: string,\n): Promise<FileTags> {\n if (cache === undefined) return extractTags(text, language);\n\n const key = tagsCacheKey(language, text);\n // Recorded before the lookup, not after: this key is what the tree holds now, so it\n // survives the sweep whether it was a hit, a miss, or a discard-and-rewrite.\n liveKeys.add(key);\n const found = cache.read(key);\n if (found === 'corrupt') {\n counts.discarded += 1;\n warnings.push({\n rule: REPO_MAP_CACHE_CORRUPT_RULE,\n explanation:\n `discarded a corrupt cache entry for ${rel} and re-extracted its tags from ` +\n `source; the entry file was deleted so it cannot be read again`,\n });\n } else if (found !== undefined) {\n counts.hits += 1;\n return found;\n } else {\n counts.misses += 1;\n }\n const tags = await extractTags(text, language);\n cache.write(key, tags);\n return tags;\n}\n\n/**\n * Walk the tree under `root`, depth-first in sorted order, returning `/`-separated\n * relative paths of every regular file that survives the ignore list.\n *\n * Symlinks are skipped outright — file or directory, in-root or out. Never following\n * one is the simplest true implementation of \"never follow a symlink out of the\n * root\": there is no resolution step to get wrong. The refusal is stated on\n * `isSymlink`, not left to the accident that an `lstat` of a link is neither file\n * nor directory — so a reader whose `stat` resolves the target is refused just the\n * same, and the guard can watch the refusal happen by counting reader calls.\n *\n * The ignore list is applied before the entry is statted, so an ignored path costs\n * nothing and is never even looked at.\n */\nfunction scanFiles(reader: RepoReader, root: string, ignore: readonly string[]): readonly string[] {\n const found: string[] = [];\n const walk = (dir: string, relDir: string): void => {\n const listed = fsCall('list the directory', dir, () => reader.list(dir));\n for (const entry of listed.map((item) => item.name).toSorted()) {\n const rel = relDir === '' ? entry : `${relDir}/${entry}`;\n if (isIgnored(rel, ignore)) continue;\n const full = join(dir, entry);\n const stat = fsCall('stat', full, () => reader.stat(full));\n if (stat === undefined) continue; // the reader has nothing there\n if (stat.isSymlink) continue;\n if (stat.isDirectory) walk(full, rel);\n else if (stat.isFile) found.push(rel);\n }\n };\n walk(root, '');\n return found;\n}\n\n/** See {@link RepoMapOptions.ignore} for the two match modes. */\nfunction isIgnored(rel: string, ignore: readonly string[]): boolean {\n for (const entry of ignore) {\n const cleaned = entry.replace(/\\/+$/, '');\n if (cleaned === '') continue;\n // Prefix mode is decided on the entry AS WRITTEN, before the trailing slash is\n // trimmed: `build/` contains a `/` and so is the documented root-relative prefix\n // (the root-level `build` tree), not a bare name that would match a `build`\n // segment at any depth.\n if (entry.includes('/')) {\n if (rel === cleaned || rel.startsWith(`${cleaned}/`)) return true;\n } else if (rel.split('/').includes(cleaned)) {\n return true;\n }\n }\n return false;\n}\n\n/** One ranked definition in fill order, with the focus term that promoted it, if any. */\ninterface OrderedDefinition {\n readonly definition: RankedDefinition;\n readonly focusTerm?: string;\n}\n\n/**\n * The fill order: focus-matched definitions first, then the rest, each partition\n * keeping the ranker's total order. A stable partition of a deterministic order is\n * itself deterministic, so the map's byte-for-byte claim survives focus untouched.\n * The match is a case-insensitive substring over name and path — the lexical\n * planner's default, so \"focus\" means the same thing in both places — and the\n * *first* matching term in caller order is the one the receipt names.\n */\nfunction orderWithFocus(\n ranked: readonly RankedDefinition[],\n focus: readonly string[],\n): readonly OrderedDefinition[] {\n if (focus.length === 0) return ranked.map((definition) => ({ definition }));\n const needles = focus.map((term) => term.toLowerCase());\n const matched: OrderedDefinition[] = [];\n const rest: OrderedDefinition[] = [];\n for (const definition of ranked) {\n const haystack = `${definition.path}\\0${definition.name}`.toLowerCase();\n const index = needles.findIndex((needle) => haystack.includes(needle));\n if (index === -1) rest.push({ definition });\n else matched.push({ definition, focusTerm: focus[index]! });\n }\n return [...matched, ...rest];\n}\n\n/** One rendered map line. Everything in it is measured, nothing estimated. */\nfunction renderDefinition(definition: RankedDefinition): string {\n return (\n `${definition.path}:${String(definition.line)} ${definition.kind} ${definition.name} ` +\n `[${String(definition.refsIn)} in from ${String(definition.refsInFiles)} ` +\n `${plural('file', definition.refsInFiles)}, ${String(definition.refsOut)} out]`\n );\n}\n\n/** Law 2, applied to inclusion: the receipt every included symbol carries. */\nfunction toEntry(definition: RankedDefinition, focusTerm?: string): RepoMapEntry {\n const site = `defined at ${definition.path}:${String(definition.line)}`;\n const counts =\n `${String(definition.refsIn)} ${plural('reference', definition.refsIn)} in ` +\n `from ${String(definition.refsInFiles)} ${plural('file', definition.refsInFiles)}; ` +\n `its file makes ${String(definition.refsOut)} ` +\n `${plural('reference', definition.refsOut)} out`;\n const reason: RepoMapReason =\n focusTerm !== undefined\n ? {\n rule: REPO_MAP_FOCUS_RULE,\n explanation: `${site}; matches focus \"${focusTerm}\"; ${counts}`,\n }\n : definition.refsIn === 0\n ? {\n rule: REPO_MAP_UNREFERENCED_RULE,\n explanation: `${site}; no references to it anywhere in the scanned tree`,\n }\n : {\n rule: REPO_MAP_RANKED_RULE,\n explanation: `${site}; ${counts}`,\n };\n return {\n path: definition.path,\n name: definition.name,\n kind: definition.kind,\n line: definition.line,\n rank: definition.rank,\n refsIn: definition.refsIn,\n refsInFiles: definition.refsInFiles,\n refsOut: definition.refsOut,\n reason,\n };\n}\n\nfunction plural(word: string, count: number): string {\n return count === 1 ? word : `${word}s`;\n}\n"]}
|