@vibe-agent-toolkit/utils 0.2.0-rc.2 → 0.2.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +26 -15
  2. package/dist/compare-code-units.d.ts +28 -0
  3. package/dist/compare-code-units.d.ts.map +1 -0
  4. package/dist/compare-code-units.js +33 -0
  5. package/dist/compare-code-units.js.map +1 -0
  6. package/dist/crawl-timing.d.ts +657 -0
  7. package/dist/crawl-timing.d.ts.map +1 -0
  8. package/dist/crawl-timing.js +765 -0
  9. package/dist/crawl-timing.js.map +1 -0
  10. package/dist/crawl.d.ts +2 -4
  11. package/dist/crawl.d.ts.map +1 -1
  12. package/dist/crawl.js +2 -4
  13. package/dist/crawl.js.map +1 -1
  14. package/dist/file-crawler.d.ts +22 -0
  15. package/dist/file-crawler.d.ts.map +1 -1
  16. package/dist/file-crawler.js +31 -7
  17. package/dist/file-crawler.js.map +1 -1
  18. package/dist/fs-utils.d.ts +17 -0
  19. package/dist/fs-utils.d.ts.map +1 -1
  20. package/dist/fs-utils.js +69 -0
  21. package/dist/fs-utils.js.map +1 -1
  22. package/dist/fs.d.ts +3 -1
  23. package/dist/fs.d.ts.map +1 -1
  24. package/dist/fs.js +5 -1
  25. package/dist/fs.js.map +1 -1
  26. package/dist/git-run.d.ts +167 -0
  27. package/dist/git-run.d.ts.map +1 -0
  28. package/dist/git-run.js +154 -0
  29. package/dist/git-run.js.map +1 -0
  30. package/dist/git-snapshot.d.ts +181 -0
  31. package/dist/git-snapshot.d.ts.map +1 -0
  32. package/dist/git-snapshot.js +232 -0
  33. package/dist/git-snapshot.js.map +1 -0
  34. package/dist/git-tracker.d.ts +94 -1
  35. package/dist/git-tracker.d.ts.map +1 -1
  36. package/dist/git-tracker.js +135 -3
  37. package/dist/git-tracker.js.map +1 -1
  38. package/dist/git-utils.d.ts +38 -0
  39. package/dist/git-utils.d.ts.map +1 -1
  40. package/dist/git-utils.js +103 -80
  41. package/dist/git-utils.js.map +1 -1
  42. package/dist/git.d.ts +12 -4
  43. package/dist/git.d.ts.map +1 -1
  44. package/dist/git.js +12 -4
  45. package/dist/git.js.map +1 -1
  46. package/dist/gitignore-checker.d.ts.map +1 -1
  47. package/dist/gitignore-checker.js +6 -3
  48. package/dist/gitignore-checker.js.map +1 -1
  49. package/dist/index.d.ts +24 -18
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +49 -30
  52. package/dist/index.js.map +1 -1
  53. package/dist/path-core.d.ts +1 -1
  54. package/dist/path-core.js +1 -1
  55. package/dist/project-utils.d.ts.map +1 -1
  56. package/dist/project-utils.js +6 -3
  57. package/dist/project-utils.js.map +1 -1
  58. package/dist/project.d.ts +7 -5
  59. package/dist/project.d.ts.map +1 -1
  60. package/dist/project.js +7 -5
  61. package/dist/project.js.map +1 -1
  62. package/dist/safe-exec.d.ts +38 -10
  63. package/dist/safe-exec.d.ts.map +1 -1
  64. package/dist/safe-exec.js +88 -19
  65. package/dist/safe-exec.js.map +1 -1
  66. package/dist/skill-test/index.d.ts +1 -1
  67. package/dist/skill-test/index.d.ts.map +1 -1
  68. package/dist/skill-test/index.js +1 -1
  69. package/dist/skill-test/index.js.map +1 -1
  70. package/dist/skill-test/spawn-claude.d.ts +28 -0
  71. package/dist/skill-test/spawn-claude.d.ts.map +1 -1
  72. package/dist/skill-test/spawn-claude.js +51 -2
  73. package/dist/skill-test/spawn-claude.js.map +1 -1
  74. package/dist/skill-test/transcript.d.ts +41 -2
  75. package/dist/skill-test/transcript.d.ts.map +1 -1
  76. package/dist/skill-test/transcript.js +47 -3
  77. package/dist/skill-test/transcript.js.map +1 -1
  78. package/dist/test-helpers.d.ts +185 -13
  79. package/dist/test-helpers.d.ts.map +1 -1
  80. package/dist/test-helpers.js +274 -27
  81. package/dist/test-helpers.js.map +1 -1
  82. package/dist/testing.d.ts +73 -3
  83. package/dist/testing.d.ts.map +1 -1
  84. package/dist/testing.js +93 -3
  85. package/dist/testing.js.map +1 -1
  86. package/dist/text-content.d.ts +197 -0
  87. package/dist/text-content.d.ts.map +1 -0
  88. package/dist/text-content.js +348 -0
  89. package/dist/text-content.js.map +1 -0
  90. package/dist/text-file.d.ts +45 -0
  91. package/dist/text-file.d.ts.map +1 -0
  92. package/dist/text-file.js +53 -0
  93. package/dist/text-file.js.map +1 -0
  94. package/dist/text.d.ts +16 -0
  95. package/dist/text.d.ts.map +1 -0
  96. package/dist/text.js +16 -0
  97. package/dist/text.js.map +1 -0
  98. package/dist/timing-dump.d.ts +136 -0
  99. package/dist/timing-dump.d.ts.map +1 -0
  100. package/dist/timing-dump.js +230 -0
  101. package/dist/timing-dump.js.map +1 -0
  102. package/eslint/README.md +105 -3
  103. package/eslint/index.cjs +50 -0
  104. package/eslint/index.d.cts +14 -4
  105. package/eslint/rules/no-bare-symlink-in-tests.cjs +200 -0
  106. package/eslint/rules/no-process-exit-in-phase.cjs +117 -0
  107. package/eslint/rules/no-raw-text-decode.cjs +296 -0
  108. package/eslint/rules/no-self-package-import.cjs +157 -0
  109. package/package.json +13 -8
  110. package/dist/link-auth/build-headers.d.ts +0 -34
  111. package/dist/link-auth/build-headers.d.ts.map +0 -1
  112. package/dist/link-auth/build-headers.js +0 -58
  113. package/dist/link-auth/build-headers.js.map +0 -1
  114. package/dist/link-auth/expand-macro.d.ts +0 -38
  115. package/dist/link-auth/expand-macro.d.ts.map +0 -1
  116. package/dist/link-auth/expand-macro.js +0 -133
  117. package/dist/link-auth/expand-macro.js.map +0 -1
  118. package/dist/link-auth/macros.yaml +0 -50
  119. package/dist/link-auth/resolve-token.d.ts +0 -83
  120. package/dist/link-auth/resolve-token.d.ts.map +0 -1
  121. package/dist/link-auth/resolve-token.js +0 -101
  122. package/dist/link-auth/resolve-token.js.map +0 -1
  123. package/dist/link-auth/resolve.d.ts +0 -102
  124. package/dist/link-auth/resolve.d.ts.map +0 -1
  125. package/dist/link-auth/resolve.js +0 -66
  126. package/dist/link-auth/resolve.js.map +0 -1
  127. package/dist/link-auth/rewrite.d.ts +0 -52
  128. package/dist/link-auth/rewrite.d.ts.map +0 -1
  129. package/dist/link-auth/rewrite.js +0 -102
  130. package/dist/link-auth/rewrite.js.map +0 -1
  131. package/dist/link-auth/select-provider.d.ts +0 -30
  132. package/dist/link-auth/select-provider.d.ts.map +0 -1
  133. package/dist/link-auth/select-provider.js +0 -55
  134. package/dist/link-auth/select-provider.js.map +0 -1
  135. package/dist/link-auth/template.d.ts +0 -40
  136. package/dist/link-auth/template.d.ts.map +0 -1
  137. package/dist/link-auth/template.js +0 -89
  138. package/dist/link-auth/template.js.map +0 -1
  139. package/dist/link-auth/transforms.d.ts +0 -46
  140. package/dist/link-auth/transforms.d.ts.map +0 -1
  141. package/dist/link-auth/transforms.js +0 -52
  142. package/dist/link-auth/transforms.js.map +0 -1
  143. package/dist/template-entry.d.ts +0 -10
  144. package/dist/template-entry.d.ts.map +0 -1
  145. package/dist/template-entry.js +0 -10
  146. package/dist/template-entry.js.map +0 -1
  147. package/dist/template.d.ts +0 -7
  148. package/dist/template.d.ts.map +0 -1
  149. package/dist/template.js +0 -18
  150. package/dist/template.js.map +0 -1
@@ -0,0 +1,765 @@
1
+ /**
2
+ * Timing accumulators for the work that *finds* documents, as opposed to the
3
+ * work that parses them.
4
+ *
5
+ * `parse-timing.ts` attributes time inside a parser. Its instrumentation points
6
+ * are exhaustively three files, and everything above them — the link walk, the
7
+ * gitignore oracle, the exclude cascade, the closure's reference resolution and
8
+ * its fixpoint iteration — is unattributed. That is not a gap in a report; it is
9
+ * the reason VAT cannot presently answer the one question that matters before
10
+ * either crawler is flipped onto a verb: **which of the two costs more to do its
11
+ * own work?**
12
+ *
13
+ * ## Why this is a keyed map and `parse-timing.ts` is a slot array
14
+ *
15
+ * The parse seam's axis is a CLOSED enum — a parser kind has the passes it has,
16
+ * they are declared in one array, and a `Float64Array` indexed by a compile-time
17
+ * constant is exactly right for a path taken 1,364+ times per command.
18
+ *
19
+ * This axis is not closed. `contributorId` is dynamic: a corpus declares its own
20
+ * extents, so on VAT's own tree there are 61 closure contributors whose ids come
21
+ * out of config, and the fixpoint `pass` is discovered at run time. A fixed-width
22
+ * slot array cannot carry either, and the honest answer is the one
23
+ * {@link ContributorTiming} already models — a keyed accumulator over
24
+ * `(contributorId, stratum, pass)`.
25
+ *
26
+ * The cost of the map is affordable *because* this path is cold relative to the
27
+ * parse path: one record per contributor invocation (66 contributors × 2 passes
28
+ * on VAT's own tree) plus one per walk and one per gitignore oracle read, against
29
+ * ~12,000 parser-pass records. The one genuinely hot site — the closure's
30
+ * per-reference resolution — is charged into a single pre-resolved key.
31
+ *
32
+ * ## Why this seam lives in `utils` and not in the package that owns a crawler
33
+ *
34
+ * Every id below names work in some *other* package — `resources` builds the
35
+ * registry, `agent-skills` walks the link graph, `claude-marketplace` enumerates
36
+ * an inventory. The seam has to sit underneath all of them, and `utils` is the
37
+ * only package that is underneath all of them.
38
+ *
39
+ * It shipped in `resources` and was moved here when {@link CRAWL_SHARED_GIT_TRACKER_ID}
40
+ * needed a bracket, because the code that row measures — `GitTracker.initialize`
41
+ * and its `git ls-files` spawn — is in *this* package, and `utils` may not import
42
+ * `resources`. The alternative was a bracket at each of the six call sites that
43
+ * construct a tracker, which is the arrangement the `ResourceRegistry` section
44
+ * below rejects for exactly the reason it gives; and it would have reached only
45
+ * five of them, because `@vibe-agent-toolkit/discovery` depends on `utils` alone
46
+ * and could not have filed a row at all.
47
+ *
48
+ * ⚠️ What that costs, stated rather than glossed: {@link CrawlStratum}'s `base`
49
+ * and `closure` are the merge driver's names, and the driver is two packages up.
50
+ * A module here now carries vocabulary from above it. That is a naming coupling
51
+ * and not a code one — nothing in this file or {@link timing-dump} imports
52
+ * anything but `node:` builtins and this package's own path helpers — but a
53
+ * reader looking for why `utils` knows the word "fixpoint" is owed the answer.
54
+ *
55
+ * ## What a `stratum` is here, and why the walker and the tracker get their own
56
+ *
57
+ * Two of the four come straight from the merge driver: `base` contributors run
58
+ * once, `closure` contributors iterate to a fixed point. `walkLinkGraph` is
59
+ * neither — it is not a projection contributor at all and the driver never sees
60
+ * it — so it records under `crawl` with a **synthetic contributor id**
61
+ * ({@link CRAWL_WALKER_ID}, {@link CRAWL_WALKER_GITIGNORE_ID}). That is stated
62
+ * here, and named in constants, rather than left to whatever string a call site
63
+ * happened to pass: a synthetic id that arrives by accident is indistinguishable
64
+ * in the dump from a real contributor, and the whole point of the dump is that
65
+ * the two crawlers are legible side by side.
66
+ *
67
+ * The fourth, `shared`, is for work **neither arm owns and both consume** — see
68
+ * its own section below. It exists because the only honest place to charge such
69
+ * work is a stratum that belongs to nobody: charging it to `crawl` would put
70
+ * shared preparation on the incumbent's total, which is the same class of defect
71
+ * as the double-count described further down, with the arms swapped.
72
+ *
73
+ * ## The two arms are bracketed at the same DEPTH, and that took a fix
74
+ *
75
+ * "Side by side" is a claim about depth, not just about presence. This seam
76
+ * shipped with the projection arm bracketed at its driver — `merge.ts` charges
77
+ * every `base` contributor, so the `base` stratum carries the projection's whole
78
+ * PREPARATION — while the incumbent arm was bracketed only at
79
+ * {@link CRAWL_WALKER_ID}, one `walkLinkGraph` call. But `walkLinkGraph` walks a
80
+ * `ResourceRegistry` somebody else already built, and building it is the crawl:
81
+ * `crawlDirectory` to enumerate, one read-parse-index per file to admit, then
82
+ * `resolveLinks` to wire the graph the walk then follows. None of that was
83
+ * charged anywhere. Measured on a real subject, the walker's traversal came in at
84
+ * **1.7 ms** against the projection's ~1,016 ms — and nothing in the output looked
85
+ * wrong, because both numbers were real and both arms reported. A ~600× ratio
86
+ * read off that dump would have been a comparison of a walk against a whole
87
+ * crawl.
88
+ *
89
+ * So the registry's own work is charged under `crawl` too
90
+ * ({@link CRAWL_REGISTRY_ENUMERATE_ID}, {@link CRAWL_REGISTRY_ADMIT_ID},
91
+ * {@link CRAWL_REGISTRY_RESOLVE_LINKS_ID}), and the brackets live INSIDE
92
+ * `ResourceRegistry` rather than at the six sites that construct one. Six copies
93
+ * of the same bracket is six chances to disagree, and a seventh construction site
94
+ * added later would silently rot the gate — the one place all six converge is the
95
+ * class itself.
96
+ *
97
+ * ### How to total an arm from this dump
98
+ *
99
+ * Not every row is additive with every other, so the two totals a flip decision
100
+ * rests on are stated here rather than left to a reader's arithmetic:
101
+ *
102
+ * - **Incumbent arm** = the three `resource-registry:*` rows (mutually disjoint —
103
+ * enumeration, admission and link resolution do not contain one another) plus
104
+ * {@link CRAWL_WALKER_ID}. **Not** {@link CRAWL_WALKER_GITIGNORE_ID}, which is
105
+ * charged from inside the walk and is therefore already inside the walk's row.
106
+ * - **Projection arm** = the driver-placed rows in `base` and `closure`, i.e.
107
+ * every row at pass ≥ 1. The pass-0 rows in those strata
108
+ * ({@link CRAWL_CLOSURE_CONTRIBUTE_ID}, {@link CRAWL_CLOSURE_RESOLVE_ID}, and a
109
+ * registry build reached from inside a contributor) are breakdowns of that same
110
+ * time, not additions to it.
111
+ * - **Neither arm** = the `shared` stratum. It is part of what the COMMAND cost
112
+ * and no part of what either crawler cost, so it belongs in a command total and
113
+ * in neither side of the side-by-side. A reader who adds it to one arm has
114
+ * answered a different question than the one they asked.
115
+ *
116
+ * ⚠️ A rollup that sums a stratum's rows without regard to pass double-counts
117
+ * every nested bracket. That is a real reading hazard, not a hypothetical: it is
118
+ * what `packages/lab/src/facets/crawl/dump.ts` did until 2026-08-15, and it
119
+ * inflated the two arms by DIFFERENT factors, because they nest to different
120
+ * depths. That reader now implements the rule above — `crawlRowRole` there is
121
+ * the executable copy of it — so anyone adding a bracket to this seam should
122
+ * expect to place it there too, and will see it land in `unclassified` if they
123
+ * do not.
124
+ *
125
+ * ## A registry built from inside a contributor belongs to the PROJECTION arm
126
+ *
127
+ * Putting the bracket inside `ResourceRegistry` puts it under whoever calls it,
128
+ * and a projection contributor could call it. Nothing shipped does — no file
129
+ * under `src/projection/` imports the class; the base contributors reach for
130
+ * `crawlDirectory`, `GitTracker` and `node:fs` directly — but "nothing does yet"
131
+ * is not an accounting rule. If a contributor ever did, charging its registry
132
+ * build to `crawl` would move a whole crawl onto the incumbent's total on a run
133
+ * the incumbent took no part in: the same defect this section describes, with the
134
+ * arms swapped.
135
+ *
136
+ * So a registry bracket does not name its own stratum. It **inherits** the one
137
+ * the merge driver is running under ({@link withContributorStratum}, an
138
+ * `AsyncLocalStorage` so it survives the `await`s a contributor is full of and
139
+ * cannot be corrupted by a second population interleaving with the first), and
140
+ * falls back to `crawl` — the incumbent — when no contributor is on the stack.
141
+ * The row is then a pass-0 breakdown of the driver's own row for that
142
+ * contributor, exactly as {@link CRAWL_CLOSURE_CONTRIBUTE_ID} already is.
143
+ *
144
+ * **Failure mode of that choice, stated plainly:** the inherited row overlaps the
145
+ * driver's row for the same invocation, so an arm total that adds them
146
+ * double-counts. The alternative — dropping the bracket while inside a
147
+ * contributor — would have removed the overlap by making real work invisible,
148
+ * and an absent row is indistinguishable from code that never ran. Overlap that
149
+ * a reader can see and the totalling rule above resolves beats a silent hole.
150
+ *
151
+ * ## `shared` is for preparation that CANCELS, and cancelling is not free
152
+ *
153
+ * A `GitTracker` is built once and handed to whichever crawler runs: both arms
154
+ * take one as a caller option, so the `git ls-files` spawn behind it is charged
155
+ * to the same side whichever way a verb is flipped. It is therefore invisible to
156
+ * the arm COMPARISON by construction — and it was, for four commits, invisible
157
+ * to the dump as well, which is a different and worse thing.
158
+ *
159
+ * The distinction the `shared` stratum draws is between two questions a reader
160
+ * asks with the same words:
161
+ *
162
+ * - *"Which crawler costs more?"* — `shared` is irrelevant, and adding it to
163
+ * either arm makes the answer wrong.
164
+ * - *"What did this command spend finding documents?"* — `shared` is part of the
165
+ * answer, and omitting it makes THAT answer wrong. On an adopter monorepo one
166
+ * `GitTracker.initialize()` measured 147 ms; the whole incumbent crawl on VAT's
167
+ * own tree measures ~75 ms. A term that can be twice the total it is missing
168
+ * from is not a rounding error.
169
+ *
170
+ * Symmetric under-counting is still under-counting. The rule this seam keeps is
171
+ * that no measured work goes unrecorded, and where a row is placed is answered
172
+ * separately from whether it exists.
173
+ *
174
+ * ⚠️ `shared` is a FALLBACK, not a destination. {@link recordSharedPass} inherits
175
+ * {@link withContributorStratum} exactly as {@link recordRegistryPass} does, for
176
+ * the same reason: a projection contributor that built its own tracker would be
177
+ * paying for it out of its own time, and charging that to `shared` would move a
178
+ * cost off the arm that actually incurred it. Nothing shipped does — the base
179
+ * contributors are handed a tracker rather than building one — but "nothing does
180
+ * yet" is not an accounting rule, and it is the rule this whole file is about.
181
+ *
182
+ * ## `pass` 0 means "recorded from inside the work"
183
+ *
184
+ * The merge driver is the ONLY participant that knows which fixpoint pass is
185
+ * running; a contributor's own `contribute` does not, and neither does a link
186
+ * walk. So a bracket placed inside the measured code records
187
+ * {@link CRAWL_PASS_INSIDE} — a reserved 0 — and aggregates across every pass.
188
+ * A driver-placed record always carries a real pass number at or above 1. The
189
+ * two are therefore never silently summed into one row: they key differently,
190
+ * and a reader can tell a per-pass figure from an all-passes one by looking.
191
+ *
192
+ * ## Commensurability is the whole point, so both arms use one clock
193
+ *
194
+ * Every bracket in this seam — driver, closure, walker — is `performance.now()`,
195
+ * for the same reason `parse-timing.ts` uses it: it is a float and allocates
196
+ * nothing, where `process.hrtime.bigint()` allocates a BigInt per call. The merge
197
+ * driver's `ContributorTiming.elapsedMs` moved to the same clock when this seam
198
+ * landed; it was `Date.now()`, whose ~1ms granularity would have made a
199
+ * driver-level figure and a walker-level figure incomparable at exactly the
200
+ * resolution the comparison needs.
201
+ *
202
+ * ## What this dump deliberately does NOT do
203
+ *
204
+ * It carries the process's own wall and CPU lifetime, like the parse dump, and
205
+ * for the same reason: these brackets are wall-timed, so a reader has to be able
206
+ * to see that the process spent its life waiting. It does **not** invite that
207
+ * figure to be summed across processes. `parse-timing.ts`'s review finding of
208
+ * 2026-08-14 records that the lab sums `process.wallMs` across dumps, which
209
+ * double-counts real time under a multi-process verb because the parent
210
+ * orchestrator's lifetime contains every child's. The reader for THIS dump
211
+ * publishes one lifetime per process and never a total — see
212
+ * `packages/lab/src/facets/crawl/dump.ts`.
213
+ *
214
+ * ## Why the gate is read at module load
215
+ *
216
+ * Same reconciliation `parse-timing.ts` states: `process.env` access in Node is a
217
+ * native call, the gate sits on paths taken thousands of times per command, and
218
+ * the testability the per-construction rule protects is preserved by
219
+ * {@link __setCrawlTimingForTest} rather than by re-reading the environment.
220
+ * (`vitest.setup.js` deletes every `VAT_*` variable before any test module loads,
221
+ * so a test could not usefully set it anyway.)
222
+ *
223
+ * The env var's VALUE is the directory the dump is written to; its presence is
224
+ * what enables the seam. An empty-string value counts as absent.
225
+ */
226
+ import { AsyncLocalStorage } from 'node:async_hooks';
227
+ import { ensureTimingDirectory, normalizeTimingDirectory, readTimingProcess, writeTimingDump, } from './timing-dump.js';
228
+ /**
229
+ * Every stratum, in the order the dump and every report list them.
230
+ *
231
+ * `shared` is appended rather than slotted next to `crawl`, so that adding it did
232
+ * not reorder a single existing row. Dump ordering is what makes two captures of
233
+ * one run comparable line by line, and a reordering is indistinguishable from a
234
+ * measurement change to anything diffing the text.
235
+ */
236
+ export const CRAWL_STRATA = ['base', 'closure', 'crawl', 'shared'];
237
+ /**
238
+ * The `pass` a bracket placed INSIDE the measured code records.
239
+ *
240
+ * Reserved, and never produced by the merge driver, which numbers its passes from
241
+ * 1. See this module's header: a contributor's own body does not know which
242
+ * fixpoint pass is running, so a row keyed here aggregates across all of them and
243
+ * says so by carrying a pass number no driver-placed row can carry.
244
+ */
245
+ export const CRAWL_PASS_INSIDE = 0;
246
+ /** Synthetic contributor id for one whole `walkLinkGraph` call. */
247
+ export const CRAWL_WALKER_ID = 'walk-link-graph:walk';
248
+ /**
249
+ * Synthetic contributor id for the link walker's gitignore oracle.
250
+ *
251
+ * Charged on the MISS path only — `WalkState.gitignoreFacts` memoizes the answer
252
+ * within one walk, and a memo hit costs nothing worth a bracket. So `calls` here
253
+ * counts oracle READS (a `git check-ignore` spawn, or a `GitTracker` active-set
254
+ * lookup), not the number of times the cascade asked.
255
+ */
256
+ export const CRAWL_WALKER_GITIGNORE_ID = 'walk-link-graph:gitignore';
257
+ /**
258
+ * Synthetic contributor id for one `ClosureExtentContributor.contribute` call,
259
+ * aggregated across every declared extent.
260
+ *
261
+ * Distinct from the driver's own `closure:<name>` rows, which are per extent and
262
+ * per fixpoint pass: this one brackets the same work from the inside, so the two
263
+ * together say how much of a contributor invocation is the contributor's body and
264
+ * how much is the driver's merge and digest around it.
265
+ */
266
+ export const CRAWL_CLOSURE_CONTRIBUTE_ID = 'closure-extent:contribute';
267
+ /** Synthetic contributor id for the closure walk's per-reference resolution. */
268
+ export const CRAWL_CLOSURE_RESOLVE_ID = 'closure-extent:resolve-reference';
269
+ /**
270
+ * What every `ResourceRegistry` id starts with.
271
+ *
272
+ * Exported because "is this row registry preparation?" is a question a reader
273
+ * asks — the three phases are one accounting unit — and a caller answering it by
274
+ * restating the prefix would drift the moment a fourth phase is bracketed.
275
+ */
276
+ export const CRAWL_REGISTRY_ID_PREFIX = 'resource-registry:';
277
+ /**
278
+ * Synthetic contributor id for the enumeration inside `ResourceRegistry.crawl` —
279
+ * the `crawlDirectory` call, and nothing that follows it.
280
+ *
281
+ * Only the enumeration, so that this row and
282
+ * {@link CRAWL_REGISTRY_ADMIT_ID} are additive rather than nested:
283
+ * `crawl()` is enumeration THEN admission, and bracketing the whole method would
284
+ * have produced a row that contains the admission row.
285
+ *
286
+ * A caller that enumerates for itself and hands paths to `addResources` files no
287
+ * row from inside the class, because its `crawlDirectory` call is outside the
288
+ * registry and therefore outside this bracket. That is a property of the class,
289
+ * not a claim that such a route enumerated nothing, and it is pinned as such in
290
+ * `crawl-timing.test.ts`.
291
+ *
292
+ * One such route ships: the marketplace inventory's `crawlSkillLinkRegistry`,
293
+ * which is the registry `vat inventory` hands the incumbent walker. It brackets
294
+ * its own enumeration and files this same row — the same accounting unit, and the
295
+ * two can never both run for one registry, so they cannot double-charge. It has
296
+ * to, because that registry is built for the INCUMBENT and never for the
297
+ * projection: unbracketed, it is a one-sided under-count on exactly the arm the
298
+ * flip decision is taken against, which is worse than a symmetric one.
299
+ */
300
+ export const CRAWL_REGISTRY_ENUMERATE_ID = 'resource-registry:enumerate';
301
+ /**
302
+ * Synthetic contributor id for one `ResourceRegistry.addResources` (or one
303
+ * standalone `addResource`) — the reads, the content keys, the parses, the
304
+ * stats, the checksums and the index writes for every file in that call.
305
+ *
306
+ * Charged even when an admission FAILS — a duplicate-id drop and an unreadable
307
+ * file both cost the read and the parse before they are refused, and a seam that
308
+ * charged only successes would report a corpus of collisions as nearly free.
309
+ *
310
+ * ## 🚨 Why this is a per-CALL row, and why it was renamed to say so
311
+ *
312
+ * It used to be `resource-registry:add-resource`, bracketing ONE FILE, and that
313
+ * was correct for exactly as long as the lane was sequential. It is not any
314
+ * more: reads and parses now happen with a bounded fan-out
315
+ * (`driveInOrder` in `@vibe-agent-toolkit/resources`), and {@link recordRegistryPass}
316
+ * SUMS each bracket's elapsed time. Overlapping per-file brackets would
317
+ * therefore total up to `width ×` the wall clock they claim to measure — the
318
+ * same shape as the instrument artifact that once reported a 6.5× parse-pool
319
+ * regression that did not exist, where nine worker dumps were summed for one
320
+ * process.
321
+ *
322
+ * ⇒ **One bracket per admission call**, which is the grain
323
+ * {@link CRAWL_BLOB_POPULATE_ID} already uses on the projection arm, so the two
324
+ * arms of the comparison are now measured the same way rather than differently.
325
+ * `calls` reads as "how many times admission ran" and stays divisible.
326
+ *
327
+ * ⭐ **The id changed because the MEANING changed while the shape did not.** A
328
+ * stored dump taken before the split carries per-file milliseconds under the old
329
+ * name; comparing it against a per-call number would be silently wrong in
330
+ * exactly the way no schema catches. With a new name the old row is simply
331
+ * absent, which a reader cannot miss.
332
+ */
333
+ export const CRAWL_REGISTRY_ADMIT_ID = 'resource-registry:admit';
334
+ /** Synthetic contributor id for one whole `ResourceRegistry.resolveLinks` call. */
335
+ export const CRAWL_REGISTRY_RESOLVE_LINKS_ID = 'resource-registry:resolve-links';
336
+ /**
337
+ * Synthetic contributor id for one run of the merge driver's blob stage —
338
+ * `populateBlobs`, which reads and parses every path the base contributors keyed
339
+ * and derives the four blob-keyed tables from it.
340
+ *
341
+ * **This is the projection's analogue of {@link CRAWL_REGISTRY_ADMIT_ID},
342
+ * and it went uncharged while that one was charged.** The asymmetry is why the
343
+ * bracket exists: the seam's whole purpose is "which of the two crawlers costs
344
+ * more to do its own work", and an omission on ONE arm biases exactly that
345
+ * comparison — unlike {@link CRAWL_SHARED_GIT_TRACKER_ID}, whose omission at
346
+ * least cancelled. It is latent only while `populate()` has no production caller;
347
+ * the increment that gives it one is the increment that would have read a
348
+ * projection total with its own parse stage missing from it.
349
+ *
350
+ * Charged in `base` at the driver's pass, NOT at {@link CRAWL_PASS_INSIDE}: the
351
+ * stage is placed BY the driver, between the strata, and a pass-0 row in a driver
352
+ * stratum means "a bracket inside a contributor invocation", which this is not.
353
+ * Pass >= 1 is what makes it additive, and additive is correct — nothing else
354
+ * brackets this time.
355
+ *
356
+ * Both driver-placed runs file this one row: the stage before the closure
357
+ * iterates, and the post-promotion run after it. Same accounting unit and the
358
+ * same argument {@link CRAWL_REGISTRY_ENUMERATE_ID} makes for its two routes, so
359
+ * `calls` reads as "how many times the stage ran" (1, or 2 when a closure
360
+ * contributor promoted a demand) and stays divisible.
361
+ */
362
+ export const CRAWL_BLOB_POPULATE_ID = 'blob-population:derive';
363
+ /**
364
+ * Synthetic contributor id for the merge driver's attempt to answer a population
365
+ * from a {@link ProjectionStore} instead of deriving it.
366
+ *
367
+ * **A row here is the only way a dump can tell a cache HIT from a subject that
368
+ * exercised nothing.** Both look identical from the totals — a hit files no
369
+ * contributor rows at all, because no contributor ran — and "nothing ran" is
370
+ * precisely the reading that has already turned one A/B into a measurement of
371
+ * noise. This row says the lane was reached and what it cost to ask.
372
+ *
373
+ * Charged in `base` at the driver's pass for the same reason
374
+ * {@link CRAWL_BLOB_POPULATE_ID} is: the driver places it, and pass >= 1 is what
375
+ * makes it additive rather than a breakdown of a bracket that does not contain it.
376
+ */
377
+ export const CRAWL_STORE_READ_ID = 'projection-store:read';
378
+ /**
379
+ * Synthetic contributor id for writing a freshly derived population back to a
380
+ * {@link ProjectionStore}.
381
+ *
382
+ * Filed only on a miss, which is what makes the pair readable: a run with a read
383
+ * row and no write row is a hit, a run with both is a miss that paid to populate
384
+ * and then paid to store, and a run with neither had no store at all.
385
+ *
386
+ * ⚠️ **Those three readings are not exhaustive.** A store that THROWS also files
387
+ * a read row and no write row, because the read is bracketed in a `finally` so
388
+ * that a hit — which runs no contributor and would otherwise leave no trace at
389
+ * all — is still visible. The error propagates and the run dies, so only a
390
+ * post-mortem reader of the dump can be misled by it; a live run cannot.
391
+ */
392
+ export const CRAWL_STORE_WRITE_ID = 'projection-store:write';
393
+ /**
394
+ * Synthetic contributor id for one `GitTracker.initialize()` — the `git ls-files`
395
+ * spawn and the active-set, ancestor and index maps built from its output.
396
+ *
397
+ * The default stratum is `shared` because a tracker is preparation both crawlers
398
+ * consume and neither owns; see this module's `shared` section.
399
+ *
400
+ * **`calls` counts real initializations, not calls to the method.** `initialize`
401
+ * returns immediately once it has run, and a bracket around that early return
402
+ * would report a caller's re-entry as work. Same rule as
403
+ * {@link CRAWL_WALKER_GITIGNORE_ID}, which counts oracle reads rather than
404
+ * questions asked, and for the same reason: a `calls` column nobody can divide by
405
+ * is a column that misleads.
406
+ *
407
+ * ⚠️ **The `new GitTracker(...)` constructor is deliberately NOT charged.** It
408
+ * resolves one path and allocates four empty containers; a bracket around it
409
+ * would measure `performance.now()` twice and file the result as a finding. This
410
+ * row is named "initialize" rather than "build" so it does not imply otherwise.
411
+ * A tracker that is constructed and never initialized therefore files no row,
412
+ * which is correct — it also spawned nothing.
413
+ */
414
+ export const CRAWL_SHARED_GIT_TRACKER_ID = 'git-tracker:initialize';
415
+ /**
416
+ * The stratum the merge driver is currently running a contributor under, or
417
+ * absent outside a contributor invocation.
418
+ *
419
+ * `AsyncLocalStorage` rather than a module-level variable because a contributor
420
+ * is a chain of `await`s: a plain flag set before the call and cleared after it
421
+ * would be observed by any other crawl that happened to resume on the event loop
422
+ * in between, and two populations in one process would corrupt each other's
423
+ * attribution. See this module's header for why the inheritance exists at all.
424
+ */
425
+ const contributorStratum = new AsyncLocalStorage();
426
+ /**
427
+ * Every synthetic id this BUILD is able to charge, whether or not it did.
428
+ *
429
+ * ## Why a dump has to say this, and why the version number could not
430
+ *
431
+ * An absent row is ambiguous in the one way that matters to a comparison: a dump
432
+ * with no `git-tracker:initialize` row is either a build that has no such bracket
433
+ * or a build that has one and never initialized a tracker. **The entries cannot
434
+ * distinguish those, and the difference decides whether two dumps are comparable
435
+ * at all** — the first case means one arm's total is missing a term the other
436
+ * arm's total contains, which is a widening read as a movement; the second means
437
+ * the arms agree and the work genuinely did not happen.
438
+ *
439
+ * A hand-bumped dump version was the previous answer and it is a poor one. An
440
+ * integer says "different", never "different how", so the remedy for a real
441
+ * widening and for a typo'd field is the same blunt refusal — and, worse, it only
442
+ * fires if a human remembers to bump it. The `shared` stratum shipped without a
443
+ * bump on an argument that was correct about rows and wrong about totals; nothing
444
+ * mechanical caught that, because nothing mechanical could.
445
+ *
446
+ * This list is derived from the module's own constants and travels in the dump,
447
+ * so a reader diffs CAPABILITIES rather than comparing an opaque number, and a
448
+ * bracket added here is announced without anyone remembering anything.
449
+ *
450
+ * ⚠️ **Synthetic ids only.** A contributor's own id comes out of a corpus's
451
+ * config (`closure:<name>`) and is not a property of the build, so it cannot go
452
+ * here — its absence really does mean "that extent was not declared", which is a
453
+ * corpus difference and not a build one. The strata are declared alongside, in
454
+ * {@link CRAWL_STRATA}, for the same reason and with no such caveat.
455
+ */
456
+ export const CRAWL_CHARGEABLE_IDS = [
457
+ CRAWL_BLOB_POPULATE_ID,
458
+ CRAWL_CLOSURE_CONTRIBUTE_ID,
459
+ CRAWL_CLOSURE_RESOLVE_ID,
460
+ CRAWL_REGISTRY_ADMIT_ID,
461
+ CRAWL_REGISTRY_ENUMERATE_ID,
462
+ CRAWL_REGISTRY_RESOLVE_LINKS_ID,
463
+ CRAWL_SHARED_GIT_TRACKER_ID,
464
+ CRAWL_STORE_READ_ID,
465
+ CRAWL_STORE_WRITE_ID,
466
+ CRAWL_WALKER_GITIGNORE_ID,
467
+ CRAWL_WALKER_ID,
468
+ ];
469
+ /** Basename stem of a dump file; the pid (and any collision counter) follow. */
470
+ const DUMP_BASENAME = 'crawl-timing';
471
+ /** What this seam is called in a failure line. */
472
+ const DUMP_NOUN = 'crawl-timing';
473
+ /**
474
+ * Every row so far, keyed by `stratum|pass|contributorId`.
475
+ *
476
+ * The id goes LAST so the key needs no escaping: a stratum is one of three
477
+ * literals and a pass is a number, so neither can contain the separator, and a
478
+ * contributor id may then contain anything at all. (A `\0` separator would have
479
+ * worked too and been unreadable — a file holding one is binary to `grep`, which
480
+ * has cost this repo a confident zero more than once.)
481
+ */
482
+ const entries = new Map();
483
+ /**
484
+ * Where dumps go, or `null` when the seam is off.
485
+ *
486
+ * Read ONCE, here, from `process.env` — see this module's header.
487
+ */
488
+ let dumpDirectory = normalizeTimingDirectory(process.env['VAT_CRAWL_TIMING']);
489
+ /**
490
+ * The hot path's gate. A plain boolean rather than `dumpDirectory !== null` so
491
+ * every instrumented call site costs one predictable branch on a memory load.
492
+ */
493
+ let timingEnabled = dumpDirectory !== null;
494
+ /**
495
+ * The accumulator key for one row.
496
+ *
497
+ * @param contributorId - A contributor's id or a synthetic one
498
+ * @param stratum - Which layer
499
+ * @param pass - The fixpoint pass, or {@link CRAWL_PASS_INSIDE}
500
+ * @returns The map key
501
+ */
502
+ function keyOf(contributorId, stratum, pass) {
503
+ return `${stratum}|${String(pass)}|${contributorId}`;
504
+ }
505
+ /**
506
+ * Fold one measured invocation into its row.
507
+ *
508
+ * @param contributorId - A contributor's id or a synthetic one
509
+ * @param stratum - Which layer
510
+ * @param pass - The fixpoint pass, or {@link CRAWL_PASS_INSIDE}
511
+ * @param elapsedMs - Wall time this invocation took
512
+ */
513
+ function addEntry(contributorId, stratum, pass, elapsedMs) {
514
+ // Demoted here rather than at each entry point, so both the bracketed
515
+ // (`recordCrawlPass`) and the pre-measured (`recordContributorInvocation`)
516
+ // routes inherit it — a contained row must not depend on which of the two the
517
+ // driver happened to use. See {@link withOuterBracket}.
518
+ const placed = insideOuterBracket.getStore() === true ? CRAWL_PASS_INSIDE : pass;
519
+ const key = keyOf(contributorId, stratum, placed);
520
+ const bucket = entries.get(key);
521
+ if (bucket === undefined) {
522
+ entries.set(key, { contributorId, stratum, pass: placed, calls: 1, elapsedMs });
523
+ return;
524
+ }
525
+ bucket.calls += 1;
526
+ bucket.elapsedMs += elapsedMs;
527
+ }
528
+ /**
529
+ * Order the rows so two dumps of the same run list them identically.
530
+ *
531
+ * Stratum in declared order rather than alphabetically — `base` really does
532
+ * precede `closure`, and sorting by name would put the walker's `crawl` rows
533
+ * between them for no reason a reader could state.
534
+ *
535
+ * @param left - One row
536
+ * @param right - Another
537
+ * @returns Standard comparator ordering
538
+ */
539
+ function compareEntries(left, right) {
540
+ const byStratum = CRAWL_STRATA.indexOf(left.stratum) - CRAWL_STRATA.indexOf(right.stratum);
541
+ if (byStratum !== 0)
542
+ return byStratum;
543
+ const byId = left.contributorId.localeCompare(right.contributorId);
544
+ if (byId !== 0)
545
+ return byId;
546
+ return left.pass - right.pass;
547
+ }
548
+ /**
549
+ * Build the dump from the current accumulator state.
550
+ *
551
+ * @returns A snapshot of every row
552
+ */
553
+ function buildDump() {
554
+ return {
555
+ pid: process.pid,
556
+ process: readTimingProcess(),
557
+ charges: { strata: [...CRAWL_STRATA], syntheticIds: [...CRAWL_CHARGEABLE_IDS] },
558
+ entries: [...entries.values()].sort(compareEntries).map((entry) => ({ ...entry })),
559
+ };
560
+ }
561
+ /**
562
+ * Write the dump, if the seam is on.
563
+ *
564
+ * @returns The path written, or `null` when the seam is off or the write failed
565
+ */
566
+ function writeDump() {
567
+ return writeTimingDump(DUMP_NOUN, dumpDirectory, DUMP_BASENAME, buildDump);
568
+ }
569
+ if (dumpDirectory !== null) {
570
+ ensureTimingDirectory(DUMP_NOUN, dumpDirectory);
571
+ // Registered ONLY when enabled: a disabled seam must not even add a listener.
572
+ process.on('exit', () => {
573
+ writeDump();
574
+ });
575
+ }
576
+ /**
577
+ * Start a bracket.
578
+ *
579
+ * @returns `performance.now()` when the seam is on, `0` when it is off
580
+ */
581
+ export function crawlTimingStart() {
582
+ return timingEnabled ? performance.now() : 0;
583
+ }
584
+ /**
585
+ * Attribute elapsed time to a `(contributorId, stratum, pass)` row.
586
+ *
587
+ * @param contributorId - A contributor's id, or one of this module's synthetic ids
588
+ * @param stratum - Which layer the work belongs to
589
+ * @param pass - The fixpoint pass, or {@link CRAWL_PASS_INSIDE} from inside the work
590
+ * @param startedAt - The value {@link crawlTimingStart} returned
591
+ */
592
+ export function recordCrawlPass(contributorId, stratum, pass, startedAt) {
593
+ if (!timingEnabled)
594
+ return;
595
+ addEntry(contributorId, stratum, pass, performance.now() - startedAt);
596
+ }
597
+ /**
598
+ * Attribute elapsed time to whichever arm invoked this work, falling back to a
599
+ * stratum the work belongs to when no arm did.
600
+ *
601
+ * **No `stratum` parameter from the CALL SITE, deliberately.** The measured code
602
+ * here — a registry build, a tracker initialization — does not know whether it is
603
+ * running for the incumbent walker or from inside a projection contributor, and a
604
+ * call site that names a stratum it cannot know is how the work of one arm ends up
605
+ * on the other's total. The answer comes from {@link withContributorStratum}
606
+ * instead.
607
+ *
608
+ * The `fallback` is what the work is when nobody claimed it, and it is per site
609
+ * rather than a constant: an unclaimed registry build was the incumbent preparing
610
+ * to walk (`crawl`), while an unclaimed tracker build was preparation for whoever
611
+ * runs (`shared`). One function with a parameter rather than two nearly identical
612
+ * ones — the branch is the only difference between them, and two copies would be
613
+ * two places for the inheritance rule to drift.
614
+ *
615
+ * @param contributorId - One of this module's synthetic ids
616
+ * @param startedAt - The value {@link crawlTimingStart} returned
617
+ * @param fallback - The stratum to charge when no contributor is on the stack
618
+ */
619
+ function recordInheritedPass(contributorId, startedAt, fallback) {
620
+ const stratum = contributorStratum.getStore() ?? fallback;
621
+ addEntry(contributorId, stratum, CRAWL_PASS_INSIDE, performance.now() - startedAt);
622
+ }
623
+ /**
624
+ * Attribute elapsed time to one of the `ResourceRegistry` phases, under whichever
625
+ * arm invoked it — the incumbent when none did.
626
+ *
627
+ * @param contributorId - One of this module's `resource-registry:` ids
628
+ * @param startedAt - The value {@link crawlTimingStart} returned
629
+ */
630
+ export function recordRegistryPass(contributorId, startedAt) {
631
+ if (!timingEnabled)
632
+ return;
633
+ recordInheritedPass(contributorId, startedAt, 'crawl');
634
+ }
635
+ /**
636
+ * Attribute elapsed time to preparation both arms consume, under whichever arm
637
+ * invoked it — `shared` when none did, which is the shipped case.
638
+ *
639
+ * See this module's `shared` section: the fallback is the point of this entry
640
+ * point, and the inheritance is what keeps it from becoming a place to hide a
641
+ * cost one arm really did incur.
642
+ *
643
+ * @param contributorId - One of this module's shared ids
644
+ * @param startedAt - The value {@link crawlTimingStart} returned
645
+ */
646
+ export function recordSharedPass(contributorId, startedAt) {
647
+ if (!timingEnabled)
648
+ return;
649
+ recordInheritedPass(contributorId, startedAt, 'shared');
650
+ }
651
+ /**
652
+ * Run one contributor invocation with its stratum on the async context, so any
653
+ * bracket reached from inside it is attributed to the projection arm.
654
+ *
655
+ * A pass-through when the seam is off: an `AsyncLocalStorage.run` per contributor
656
+ * is cheap, but the shipped default is "no instrumentation ran at all", and this
657
+ * keeps that literally true.
658
+ *
659
+ * @param stratum - The stratum the driver is running this contributor in
660
+ * @param run - The invocation
661
+ * @returns Whatever the invocation returns
662
+ */
663
+ export function withContributorStratum(stratum, run) {
664
+ if (!timingEnabled)
665
+ return run();
666
+ return contributorStratum.run(stratum, run);
667
+ }
668
+ /**
669
+ * Whether the work now running is already inside a top-level span this seam
670
+ * timed.
671
+ *
672
+ * `AsyncLocalStorage`, for {@link contributorStratum}'s reason: the work in
673
+ * between is full of `await`s.
674
+ */
675
+ const insideOuterBracket = new AsyncLocalStorage();
676
+ /**
677
+ * Run work that is CONTAINED by a bracket the caller has already opened, so its
678
+ * own rows are recorded as nested rather than additive.
679
+ *
680
+ * ## The double count this closes
681
+ *
682
+ * {@link crawlRowRole}'s rule reads *"`pass >= 1` is additive, whatever the
683
+ * stratum — only the merge driver numbers passes, and nothing in a dump can
684
+ * contain a driver-placed row."* The second half was false in exactly one
685
+ * place: `ResourceRegistry.crawl` brackets its enumeration as
686
+ * {@link CRAWL_REGISTRY_ENUMERATE_ID}, and on the projection lane that
687
+ * enumeration IS a whole `populate()` — so every `base` row sat inside a
688
+ * top-level `crawl` row and both were added.
689
+ *
690
+ * Measured on this repository before the fix: `enumerate` 7,508.4 ms against
691
+ * `base` rows totalling 7,501.4 ms — the same work, twice, printed as
692
+ * `base 49.6% / crawl 50.0%`, which reads as an even split between two
693
+ * crawlers. `resource-registry.ts` documented the hazard in a comment
694
+ * (*"summing the two per-arm totals inflates the projection arm... corrupts the
695
+ * RATIO and not merely the total"*) and the instrument printed the sum anyway.
696
+ *
697
+ * ## Why demoting the pass is the whole fix
698
+ *
699
+ * A pass-0 row in a driver stratum is **already** classified as nested by the
700
+ * existing rule, and the renderer already prints nested time on its own line
701
+ * and marks the rows `⊂`. So the containment only ever needed to reach the row,
702
+ * and no dump-format change, no new role and no new render path is involved.
703
+ *
704
+ * The cost is that a contained contributor's fixpoint pass is no longer
705
+ * readable — a pass-0 row aggregates across passes. That is the correct trade:
706
+ * a pass number is a detail, and a total that double-counts is a wrong answer.
707
+ *
708
+ * @param run - The contained work
709
+ * @returns Whatever the work returns
710
+ */
711
+ export function withOuterBracket(run) {
712
+ if (!timingEnabled)
713
+ return run();
714
+ return insideOuterBracket.run(true, run);
715
+ }
716
+ /**
717
+ * Attribute an already-measured invocation, as the merge driver reports it.
718
+ *
719
+ * A second entry point rather than a second clock: the driver has to build a
720
+ * `ContributorTiming` for its own `onContributorTiming` observer anyway, so it
721
+ * measures once and hands the same object to both. Bracketing it here as well
722
+ * would time the observer.
723
+ *
724
+ * @param timing - What one contributor invocation cost
725
+ */
726
+ export function recordContributorInvocation(timing) {
727
+ if (!timingEnabled)
728
+ return;
729
+ addEntry(timing.contributorId, timing.stratum, timing.pass, timing.elapsedMs);
730
+ }
731
+ /**
732
+ * TEST ONLY. Turn the seam on (writing to `directory`) or off, and drop every
733
+ * accumulated row.
734
+ *
735
+ * Exists so tests never have to mutate the real `process.env` — the same
736
+ * justification `__setParseTimingForTest` states. It deliberately does NOT
737
+ * register an `exit` listener; a test drives the write itself via
738
+ * {@link __writeCrawlTimingDumpForTest}, so a test run never litters dumps.
739
+ *
740
+ * @param directory - Where {@link __writeCrawlTimingDumpForTest} writes, or `null` to disable
741
+ */
742
+ export function __setCrawlTimingForTest(directory) {
743
+ dumpDirectory = normalizeTimingDirectory(directory ?? undefined);
744
+ timingEnabled = dumpDirectory !== null;
745
+ entries.clear();
746
+ if (dumpDirectory !== null)
747
+ ensureTimingDirectory(DUMP_NOUN, dumpDirectory);
748
+ }
749
+ /**
750
+ * TEST ONLY. Read the accumulators without writing anything.
751
+ *
752
+ * @returns The dump that would be written right now
753
+ */
754
+ export function __readCrawlTimingSnapshot() {
755
+ return buildDump();
756
+ }
757
+ /**
758
+ * TEST ONLY. Write a dump now, exactly as the exit listener would.
759
+ *
760
+ * @returns The path written, or `null` when the seam is off or the write failed
761
+ */
762
+ export function __writeCrawlTimingDumpForTest() {
763
+ return writeDump();
764
+ }
765
+ //# sourceMappingURL=crawl-timing.js.map