mandrel 2.32.0 → 2.34.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.
Files changed (48) hide show
  1. package/.agents/docs/SDLC.md +8 -5
  2. package/.agents/docs/agentrc-reference.json +2 -1
  3. package/.agents/docs/configuration.md +3 -2
  4. package/.agents/runtime-deps.json +2 -1
  5. package/.agents/schemas/agentrc.schema.json +8 -2
  6. package/.agents/scripts/README.md +9 -0
  7. package/.agents/scripts/audit-to-stories.js +160 -41
  8. package/.agents/scripts/check-knip-entries.js +47 -24
  9. package/.agents/scripts/check-lifecycle-lint.js +72 -12
  10. package/.agents/scripts/coverage-capture.js +7 -1
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
  12. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  13. package/.agents/scripts/lib/baselines/kernel.js +20 -7
  14. package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
  15. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
  16. package/.agents/scripts/lib/config/quality.js +7 -0
  17. package/.agents/scripts/lib/config/runners.js +38 -16
  18. package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
  19. package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
  20. package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
  21. package/.agents/scripts/lib/coverage-capture.js +10 -15
  22. package/.agents/scripts/lib/dependency-parser.js +20 -7
  23. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  24. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  25. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  26. package/.agents/scripts/lib/knip-entry-sync.js +78 -39
  27. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  29. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
  30. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  31. package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
  32. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  34. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  35. package/.agents/scripts/lib/story-body/story-body.js +6 -22
  36. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  37. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  38. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  39. package/.agents/scripts/resolve-stories.js +21 -5
  40. package/.agents/scripts/stories-wave-tick.js +192 -9
  41. package/.agents/workflows/audit-to-stories.md +26 -0
  42. package/.agents/workflows/helpers/deliver-light.md +5 -2
  43. package/.agents/workflows/helpers/deliver-reference.md +28 -1
  44. package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
  45. package/.agents/workflows/helpers/deliver-story.md +4 -2
  46. package/.agents/workflows/helpers/plan-reference.md +76 -0
  47. package/docs/CHANGELOG.md +26 -0
  48. package/package.json +3 -3
@@ -0,0 +1,97 @@
1
+ /**
2
+ * footer-block.js — the Story body's `---` footer grammar.
3
+ *
4
+ * A Story body is prose an operator edits. Its **declared dependency edges**
5
+ * are not: they live in a footer block, in one exact line shape, and that
6
+ * distinction is the whole safety property. An unanchored whole-body scan
7
+ * (what `parseBlockedBy` used to be) turned any sentence that merely mentioned
8
+ * a blocker into a real dispatch gate — an example, a changelog note, an
9
+ * acceptance criterion quoting the phrase — and withheld the Story until an
10
+ * unrelated issue closed.
11
+ *
12
+ * This module is the single home for that grammar. Both readers go through it:
13
+ * `lib/story-body/story-body.js` (what a body round-trips as `depends_on`) and
14
+ * `lib/dependency-parser.js` (what gates dispatch). Sharing one implementation
15
+ * is what keeps them from drifting apart into two different answers about the
16
+ * same body.
17
+ *
18
+ * @module lib/story-body/footer-block
19
+ */
20
+
21
+ /**
22
+ * The one line shape that declares a dependency edge: `blocked by #N` alone on
23
+ * its own line inside the footer block. Anchored at both ends deliberately —
24
+ * `depends on #N`, `Blocked by: #N`, and `blocked by #N once X lands` all
25
+ * declare nothing.
26
+ */
27
+ const FOOTER_BLOCKED_BY_LINE_RE = /^blocked by\s+(#\d+)$/i;
28
+
29
+ /** A `---` rule on its own line. */
30
+ const FOOTER_RULE_RE = /^---\s*$/;
31
+
32
+ /** Footer keys that qualify a bare `---` rule as the footer separator. */
33
+ const FOOTER_KEY_RE = /^(parent:|Epic:|blocked by)/im;
34
+
35
+ /**
36
+ * True when line `index` opens the footer block: a `---` on its own line whose
37
+ * remaining lines start with a recognised footer key (`parent:`, `Epic:`,
38
+ * `blocked by`). A `---` opening a thematic break or a table mid-body is
39
+ * therefore not mistaken for the footer.
40
+ *
41
+ * @param {string} line
42
+ * @param {string[]} lines
43
+ * @param {number} index
44
+ * @returns {boolean}
45
+ */
46
+ export function isFooterSeparator(line, lines, index) {
47
+ if (!FOOTER_RULE_RE.test(line)) return false;
48
+ return FOOTER_KEY_RE.test(lines.slice(index + 1).join('\n'));
49
+ }
50
+
51
+ /**
52
+ * Return the footer block of a body — everything after the footer separator —
53
+ * or `''` when the body carries no footer.
54
+ *
55
+ * Module-private: `parseFooterBlockedByIds` is its only caller. The body
56
+ * parser splits its own sections and reaches for `parseFooterBlockedByRefs`
57
+ * with the footer it already has, so exporting this would ship a symbol with
58
+ * no consumer.
59
+ *
60
+ * @param {string} body
61
+ * @returns {string}
62
+ */
63
+ function extractFooterBlock(body) {
64
+ if (!body) return '';
65
+ const lines = String(body).split('\n');
66
+ const start = lines.findIndex((line, i) => isFooterSeparator(line, lines, i));
67
+ return start === -1 ? '' : lines.slice(start + 1).join('\n');
68
+ }
69
+
70
+ /**
71
+ * Extract the `blocked by #N` refs from an already-split footer block, as the
72
+ * `"#N"` strings a Story body's `depends_on` field round-trips.
73
+ *
74
+ * @param {string} footerBlock
75
+ * @returns {string[]}
76
+ */
77
+ export function parseFooterBlockedByRefs(footerBlock) {
78
+ if (!footerBlock) return [];
79
+ return String(footerBlock)
80
+ .split('\n')
81
+ .map((line) => line.trim().match(FOOTER_BLOCKED_BY_LINE_RE)?.[1])
82
+ .filter((ref) => typeof ref === 'string');
83
+ }
84
+
85
+ /**
86
+ * Parse a body's declared blocker issue **numbers**, deduped — the
87
+ * dispatch-edge view of the same footer the body parser reads.
88
+ *
89
+ * @param {string} body
90
+ * @returns {number[]}
91
+ */
92
+ export function parseFooterBlockedByIds(body) {
93
+ const ids = parseFooterBlockedByRefs(extractFooterBlock(body)).map((ref) =>
94
+ Number.parseInt(ref.slice(1), 10),
95
+ );
96
+ return [...new Set(ids)];
97
+ }
@@ -48,6 +48,7 @@ import {
48
48
  } from '../framework-version.js';
49
49
  import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
50
50
  import { suggestPathEntryFix } from './body-format-lints.js';
51
+ import { isFooterSeparator, parseFooterBlockedByRefs } from './footer-block.js';
51
52
 
52
53
  // ---------------------------------------------------------------------------
53
54
  // Public types (JSDoc only — no runtime schema file)
@@ -313,16 +314,15 @@ function pathEntryFixIt(raw) {
313
314
  * Extract the `blocked by #N` lines from the footer block (text after
314
315
  * the last `---` separator). Returns an array of "#N" strings.
315
316
  *
317
+ * Delegates to `./footer-block.js`, which owns the footer-block grammar
318
+ * (Story #5046) so the body parser and the dispatch-edge parser cannot
319
+ * disagree about what declares an edge.
320
+ *
316
321
  * @param {string} footerBlock
317
322
  * @returns {string[]}
318
323
  */
319
324
  function extractBlockedBy(footerBlock) {
320
- const deps = [];
321
- for (const line of footerBlock.split('\n')) {
322
- const m = line.trim().match(/^blocked by\s+(#\d+)$/i);
323
- if (m) deps.push(m[1]);
324
- }
325
- return deps;
325
+ return parseFooterBlockedByRefs(footerBlock);
326
326
  }
327
327
 
328
328
  // Matches any trailing `<!-- meta: … -->` block. Object payloads are the
@@ -491,22 +491,6 @@ function splitSections(markdown) {
491
491
  return { sections, footer, preamble };
492
492
  }
493
493
 
494
- /**
495
- * True when line `index` opens the footer block: a `---` on its own line
496
- * whose remaining lines start with a recognised footer key (`parent:`,
497
- * `Epic:`, `blocked by`).
498
- *
499
- * @param {string} line
500
- * @param {string[]} lines
501
- * @param {number} index
502
- * @returns {boolean}
503
- */
504
- function isFooterSeparator(line, lines, index) {
505
- if (!/^---\s*$/.test(line)) return false;
506
- const remaining = lines.slice(index + 1).join('\n');
507
- return /^(parent:|Epic:|blocked by)/im.test(remaining);
508
- }
509
-
510
494
  /**
511
495
  * True for a non-canonical markdown heading that TERMINATES the current
512
496
  * structured section. Trailing extended content a producer appends after the
@@ -0,0 +1,306 @@
1
+ /**
2
+ * lib/wave-runner/footprint.js — what a Story is going to touch, and what two
3
+ * Stories would touch in common.
4
+ *
5
+ * Split out of `ready-set.js` (Story #5044), which is the *scheduling* kernel:
6
+ * eligibility, capacity, admission order. Deciding whether two Stories collide
7
+ * is a separate question with its own rules — what counts as a declaration,
8
+ * what counts as evidence, what a glob means, and which text is edit intent
9
+ * rather than machine-generated noise — and it had grown large enough inside
10
+ * the scheduler to obscure both.
11
+ *
12
+ * The layer has exactly one job: given two Story records, say whether their
13
+ * file footprints intersect, name the paths, and say whether a declaration or
14
+ * the text scrape produced the answer. It reads nothing and mutates nothing.
15
+ *
16
+ * @module lib/wave-runner/footprint
17
+ */
18
+
19
+ /**
20
+ * Why two footprints collided.
21
+ *
22
+ * `declared-overlap` — at least one colliding path was **declared** by both
23
+ * Stories (or is a declared glob). This is the guard doing its intended job:
24
+ * two Stories that both list `baselines/maintainability.json` really do have to
25
+ * be serialized, and no amount of scrape-narrowing should change that.
26
+ *
27
+ * `scraped-overlap` — every colliding path reached the comparison through the
28
+ * evidence widening rather than a declaration. Still a real signal (Story
29
+ * #4875 exists because declarations are systematically a lower bound), but it
30
+ * is the class where a false positive is possible, so it is the one an operator
31
+ * should be able to see and — via `footprintGuard: 'advisory'` — choose not to
32
+ * enforce.
33
+ */
34
+ export const OVERLAP_SOURCES = Object.freeze({
35
+ DECLARED: 'declared-overlap',
36
+ SCRAPED: 'scraped-overlap',
37
+ });
38
+
39
+ /**
40
+ * Repo-relative file paths as they appear in Story prose: at least one `/`
41
+ * separator and a short file extension. Deliberately narrow — a token has to
42
+ * look like a real path before it can widen a footprint and withhold a Story.
43
+ */
44
+ const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
45
+
46
+ /**
47
+ * The **machine-generated audit provenance footers** — and nothing else.
48
+ *
49
+ * `/audit-to-stories` and `plan-persist` stamp `<!-- audit-fingerprints: … -->`
50
+ * and `<!-- audit-semantic-keys: … -->` onto a Story body as dedup identity.
51
+ * A semantic key is `area␟primaryFile`, and that `␟` (U+241F) separator is
52
+ * outside {@link PROSE_PATH_RE}'s character class, so the `primaryFile` half
53
+ * matches as a standalone path token. `plan-persist` carries the **sweep-wide
54
+ * union** of those footers onto every sibling of an audit-derived plan, so
55
+ * every pair of that plan shared path-shaped tokens neither Story would edit —
56
+ * measured at 10/10 colliding pairs, 0/10 once these blocks are ignored
57
+ * (issue #5040).
58
+ *
59
+ * **This is surgical on purpose: a blanket HTML-comment strip would be wrong.**
60
+ * `.agents/instructions.md` § 7 puts a complexity decomposition's numbered
61
+ * sub-steps inside a `<!-- DECOMPOSITION -->` block, and the paths a sub-step
62
+ * names are exactly the edit intent this layer exists to read. Only the two
63
+ * provenance markers below are removed; every other comment stays evidence.
64
+ */
65
+ const PROVENANCE_FOOTER_RE =
66
+ /<!--\s*audit-(?:fingerprints|semantic-keys)\s*:[\s\S]*?-->/g;
67
+
68
+ /**
69
+ * The URL interior of a markdown inline link (`](…)`), with an optional title.
70
+ * A link target is a *citation* — "see [the spec](docs/architecture.md)" — not
71
+ * a declaration that this Story will edit that file, and generated bodies cite
72
+ * the same source report from every sibling. The link **text** is left intact:
73
+ * a human writing "the caller in [`bin/mandrel.js`](bin/mandrel.js)" is naming
74
+ * an edit target in the prose half, and that half still counts.
75
+ */
76
+ const MARKDOWN_LINK_URL_RE = /\]\(\s*[^)\s]*(?:\s+"[^"]*")?\s*\)/g;
77
+
78
+ /** Default gitignored scratch root when no `project.paths.tempRoot` is threaded. */
79
+ const DEFAULT_TEMP_ROOT = 'temp';
80
+
81
+ /**
82
+ * Extract a Story's declared file footprint as a normalized set of path
83
+ * strings. Accepts the three footprint shapes a Story record can carry:
84
+ *
85
+ * - `files: string[]` — explicit footprint.
86
+ * - `changes: string[]` — string-array sketch.
87
+ * - `changeset: Array<{ path }>` / — object-array sketch (the
88
+ * `changes: Array<{ path }>` `{ path, assumption }`
89
+ * shape from a Story body).
90
+ *
91
+ * Paths are trimmed; empty / non-string entries are dropped. A Story with
92
+ * no declared footprint yields an empty set, which (by {@link detectCollision}'s
93
+ * contract) means it overlaps with nothing and is never withheld.
94
+ *
95
+ * @param {object} story
96
+ * @returns {Set<string>}
97
+ */
98
+ export function storyFootprint(story) {
99
+ const out = new Set();
100
+ const push = (entry) => {
101
+ const path =
102
+ typeof entry === 'string'
103
+ ? entry
104
+ : typeof entry?.path === 'string'
105
+ ? entry.path
106
+ : null;
107
+ const trimmed = path?.trim();
108
+ if (trimmed) out.add(trimmed);
109
+ };
110
+ for (const shape of [story?.files, story?.changes, story?.changeset]) {
111
+ if (Array.isArray(shape)) for (const entry of shape) push(entry);
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Does a declared path contain a glob metacharacter? Mirrors the detection
118
+ * in `story-body.js#extractChangePaths`, whose `isGlob` flag documents an
119
+ * "unknown-width footprint" policy that was never implemented downstream.
120
+ *
121
+ * @param {string} path
122
+ * @returns {boolean}
123
+ */
124
+ function isGlobPath(path) {
125
+ return path.includes('*') || path.includes('?') || path.includes('{');
126
+ }
127
+
128
+ /**
129
+ * Is this path inside the gitignored temp root?
130
+ *
131
+ * `project.paths.tempRoot` is scratch space by contract
132
+ * ([`.agents/instructions.md`](../../../instructions.md) § 6): nothing under it
133
+ * is ever committed, so it can never be a delivery write target, and two
134
+ * Stories naming the same `temp/audits/audit-<lens>-results.md` source report
135
+ * are not racing anything. The match is rooted, not a substring test, so a real
136
+ * deliverable like `lib/temperature.js` is untouched.
137
+ *
138
+ * @param {string} path
139
+ * @param {string} tempRoot
140
+ * @returns {boolean}
141
+ */
142
+ function isUnderTempRoot(path, tempRoot) {
143
+ if (!tempRoot) return false;
144
+ const normalized = path.replace(/^\.\//, '');
145
+ return normalized === tempRoot || normalized.startsWith(`${tempRoot}/`);
146
+ }
147
+
148
+ /**
149
+ * Scrape file paths a Story's **text** mentions but its `changes[]` never
150
+ * declared (Story #4875), **narrowed to text that expresses edit intent**
151
+ * (Story #5044).
152
+ *
153
+ * The declared footprint is a planner's *prediction*, and it is systematically
154
+ * a lower bound: a Story's `## Spec` names the module it must also touch, its
155
+ * acceptance criteria name the caller that must be updated, and none of that
156
+ * reaches `changes[]`. The overlap guard exists to stop two Stories racing the
157
+ * same file, so trusting the declaration outright means the guard is blind to
158
+ * precisely the collisions nobody predicted.
159
+ *
160
+ * But the converse failure is just as real: a path-shaped token that no human
161
+ * wrote as intent manufactures a collision, and a manufactured collision
162
+ * serializes a run that had no reason to be serial. Three token sources are
163
+ * therefore excluded before the scrape, each because it is *structurally*
164
+ * incapable of naming an edit target:
165
+ *
166
+ * 1. **Audit provenance footers** ({@link PROVENANCE_FOOTER_RE}) — machine-
167
+ * stamped dedup identity, unioned sweep-wide across siblings.
168
+ * 2. **Markdown-link URLs** ({@link MARKDOWN_LINK_URL_RE}) — citations.
169
+ * 3. **Paths under the temp root** ({@link isUnderTempRoot}) — gitignored
170
+ * scratch, never a write target.
171
+ *
172
+ * Evidence is only ever **added** to the declaration — nothing here can shrink
173
+ * a declared footprint, so `changes[]` remains a lower bound (Story #4875) and
174
+ * narrowing the scrape can never co-dispatch a pair the declared comparison
175
+ * would have caught.
176
+ *
177
+ * @param {object} story
178
+ * @param {object} [options]
179
+ * @param {string} [options.tempRoot='temp'] Resolved `project.paths.tempRoot`.
180
+ * @returns {Set<string>}
181
+ */
182
+ function storyEvidencePaths(story, { tempRoot = DEFAULT_TEMP_ROOT } = {}) {
183
+ const out = new Set();
184
+ for (const field of [story?.title, story?.body, story?.spec]) {
185
+ if (typeof field !== 'string') continue;
186
+ const scannable = field
187
+ .replace(PROVENANCE_FOOTER_RE, ' ')
188
+ .replace(MARKDOWN_LINK_URL_RE, ']()');
189
+ for (const [token] of scannable.matchAll(PROSE_PATH_RE)) {
190
+ if (!isUnderTempRoot(token, tempRoot)) out.add(token);
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * A Story's declared footprint **and** the evidence-widened one.
198
+ *
199
+ * Both are returned because they answer different questions. `widened` decides
200
+ * *whether* two Stories collide; `declared` decides *how to describe* the
201
+ * collision — a path both Stories declared is intended serialization (two
202
+ * Stories that really do rewrite the same generated baseline), while one only
203
+ * the scrape produced may be an artifact of how a body was worded. An operator
204
+ * reading an unfilled slot needs to tell those apart.
205
+ *
206
+ * @param {object} story
207
+ * @param {object} [options]
208
+ * @returns {{ declared: Set<string>, widened: Set<string> }}
209
+ */
210
+ function storyFootprints(story, options) {
211
+ const declared = storyFootprint(story);
212
+ const widened = new Set(declared);
213
+ for (const path of storyEvidencePaths(story, options)) widened.add(path);
214
+ return { declared, widened };
215
+ }
216
+
217
+ /**
218
+ * Record one colliding path, remembering whether **any** occurrence of it was
219
+ * declaration-backed.
220
+ *
221
+ * `declared` is tracked per path rather than per pair because the two hit kinds
222
+ * qualify differently: a shared **concrete** path counts as declared only when
223
+ * both sides declared it, whereas a **glob** names no file to share and counts
224
+ * as declared when its own side declared it. The scraper cannot emit a glob —
225
+ * prose globs are narrative ("everything under `.agents/**`") and never match
226
+ * {@link PROSE_PATH_RE} — so a glob hit is essentially always declared width
227
+ * failing safe, and labelling it `scraped` would make advisory mode read as if
228
+ * the text widening had caused it.
229
+ *
230
+ * @param {Map<string, boolean>} hits
231
+ * @param {string} path
232
+ * @param {boolean} declared
233
+ */
234
+ function recordHit(hits, path, declared) {
235
+ hits.set(path, (hits.get(path) ?? false) || declared);
236
+ }
237
+
238
+ /**
239
+ * Collect the glob paths on one side. A glob is unknown width, and unknown
240
+ * width is not no width: within a beat it collides with everything, because
241
+ * exact-string comparison would silently pass a Story declaring
242
+ * `.agents/scripts/lib/**` alongside one declaring a file underneath it
243
+ * (Story #4539/#4540).
244
+ *
245
+ * @param {Map<string, boolean>} hits
246
+ * @param {{ declared: Set<string>, widened: Set<string> }} side
247
+ */
248
+ function recordGlobs(hits, side) {
249
+ for (const path of side.widened) {
250
+ if (isGlobPath(path)) recordHit(hits, path, side.declared.has(path));
251
+ }
252
+ }
253
+
254
+ /**
255
+ * The colliding paths between two Stories' widened footprints, tagged with
256
+ * whether a declaration produced the collision — or `null` when they do not
257
+ * collide.
258
+ *
259
+ * **An empty footprint means "no known overlap"**, so this short-circuits to
260
+ * `null` on one. That is permissive by necessity: a Story with no declared
261
+ * footprint and no path evidence in its text carries no information, and
262
+ * withholding on absence would serialize every run.
263
+ *
264
+ * `concreteOnly` selects between the two guards' deliberately different
265
+ * treatment of unknown width (Story #4960). The beat-local guard counts a glob
266
+ * on either side as colliding with everything; the cross-beat reservation
267
+ * ignores globs entirely, because an in-flight Story holds its footprint for a
268
+ * whole implementation window and one glob would otherwise withhold the entire
269
+ * run for hours — and `resolve-stories.js` substitutes an UNKNOWN sentinel for
270
+ * any body it cannot parse, so one malformed Story would make a run serial.
271
+ *
272
+ * @param {object} a
273
+ * @param {object} b
274
+ * @param {object} [options]
275
+ * @param {boolean} [options.concreteOnly=false] Skip glob paths on both sides.
276
+ * @param {string} [options.tempRoot]
277
+ * @returns {{ paths: string[], source: string }|null}
278
+ */
279
+ export function detectCollision(
280
+ a,
281
+ b,
282
+ { concreteOnly = false, ...evidence } = {},
283
+ ) {
284
+ const fa = storyFootprints(a, evidence);
285
+ if (fa.widened.size === 0) return null;
286
+ const fb = storyFootprints(b, evidence);
287
+ if (fb.widened.size === 0) return null;
288
+
289
+ const hits = new Map();
290
+ for (const path of fa.widened) {
291
+ if (!isGlobPath(path) && fb.widened.has(path)) {
292
+ recordHit(hits, path, fa.declared.has(path) && fb.declared.has(path));
293
+ }
294
+ }
295
+ if (!concreteOnly) {
296
+ recordGlobs(hits, fa);
297
+ recordGlobs(hits, fb);
298
+ }
299
+ if (hits.size === 0) return null;
300
+ return {
301
+ paths: [...hits.keys()].sort(),
302
+ source: [...hits.values()].some(Boolean)
303
+ ? OVERLAP_SOURCES.DECLARED
304
+ : OVERLAP_SOURCES.SCRAPED,
305
+ };
306
+ }