mandrel 2.32.0 → 2.33.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/.agents/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +1 -0
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -12
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +57 -0
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +16 -0
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
+
}
|