Everything in here was measured, not read off a blog. Where a number appears it came from a real machine, and the Windows build is named so you can tell whether it still applies to yours.
- You cannot set the crawl scope from the registry. Not even as an elevated Administrator.
- The registry will happily show you rules that are not in effect.
- The only durable route is
ISearchCrawlScopeManager, which is what the Indexing Options dialog calls. - Those interfaces ship no type library, and the IIDs commonly published for them are not always the ones a given Windows build implements.
The rules live under:
HKLM\SOFTWARE\Microsoft\Windows Search\CrawlScopeManager\Windows\SystemIndex\
DefaultRules the service's own baseline
WorkingSetRules a derived cache, normalised from the others on startup
UserScopeRules what the API writes
Their ACLs, on build 10.0.26100:
DefaultRules
NT SERVICE\TrustedInstaller FullControl
NT AUTHORITY\SYSTEM SetValue, CreateSubKey, CreateLink, ReadKey
BUILTIN\Administrators CreateLink, ReadKey <-- no write
BUILTIN\Users CreateLink, ReadKey
NT SERVICE\WSearch FullControl
UserScopeRules
not readable or writable by Administrators at all
An elevated New-Item under UserScopeRules fails with Access to the registry
key is denied. Administrators can still delete some subkeys, which is a
trap: deletion appears to work, and then the WSearch service regenerates
DefaultRules from its built-in baseline on its next start. Whole-drive rules
deleted at 17:16 on the reference machine were back by 17:19.
The service only restores what it considers a built-in default: one whole-drive rule per fixed volume currently present. Anything else stays deleted. On the reference machine that permanently removed:
- 34 whole-drive rules for volumes no longer attached (25 of them for one removable drive letter that had seen 25 different sticks)
- stale Office content paths
- all 794 entries in
WorkingSetRules, which is a derived cache and safe to wipe. Nearly all of them were individual dependency subdirectories from projects deleted long ago, and every crawl decision walked that list.
DefaultRules went from 64 entries to 23. That is what
Clear-SearchRuleJunk.ps1 is for, and it is all it claims to do.
This is the one that costs people days.
On the reference machine, DefaultRules contained:
include=1 file:///B:\[986ad7c0-…]\
include=1 file:///D:\[784df635-…]\
Two whole drives, marked included, sitting right there in the registry. Asked through the API:
IncludedInCrawlScope("file:///B:\Coding\") -> False
IncludedInCrawlScope("file:///D:\Documents\") -> False
Both drives were entirely out of scope. The rules were inert. The reported
symptom, "Explorer search never finds my projects", was not an index full of
node_modules drowning out the signal. The indexer had never looked at that
drive at all.
So: never diagnose crawl scope by reading the registry.
.\Set-SearchScope.ps1 -Test 'C:\Projects','C:\Projects\app\node_modules'A related trap. Crawl rules identify a volume like this:
file:///B:\[986ad7c0-…]\
That bracketed GUID is not the NTFS volume GUID. For the same drive at the same moment:
mountvol B: /L -> \\?\Volume{0ecee025-…}\
crawl rules -> B:\[986ad7c0-…]
Completely different identifiers. A rule written with the mountvol GUID names
a volume the indexer does not recognise, and it is silently ignored: no
error, no warning, it simply never matches anything. If you must construct one
of these strings, learn the identifier from the indexer's own existing rules,
never from mountvol.
ExplorerTune sidesteps this entirely by going through the API with plain paths.
A * in a crawl rule matches exactly one path segment. The shipped defaults
prove it themselves:
include=0 file:///*\$RECYCLE.BIN\
include=0 file:///C:\[...]\Users\*\AppData\
There is no **\node_modules. So excluding a directory name means one rule per
nesting depth:
file:///C:\Projects\*\node_modules\
file:///C:\Projects\*\*\node_modules\
file:///C:\Projects\*\*\*\node_modules\
...
Hence NoiseDepths in the config. Thirteen names at five depths is 130 rules for
one include path. That looks absurd and it is the only way to express it. Do not
"simplify" the generated rules into one.
For calibration, the reference machine had node_modules at 27 places one level
below the project root, 61 at two levels, and 361 at three. Five depths covers a
normal monorepo.
Set-SearchScope.ps1 uses:
CSearchManager CLSID 7D096C5F-AC08-4F1F-BEB7-5C22C517CE39
ISearchManager IID AB310581-AC80-11D1-8DF3-00C04FB6EF69
GetCatalog slot 8
ISearchCatalogManager IID discovered at runtime
get_Name slot 1
GetCatalogStatus slot 4
NumberOfItems slot 13
GetCrawlScopeManager slot 26
ISearchCrawlScopeManager IID discovered at runtime
AddUserScopeRule slot 6
IncludedInCrawlScope slot 11
SaveAll slot 14
Slots are 1-based method positions after the three IUnknown entries.
On build 10.0.26100 the object GetCatalog returns does not implement the
IIDs usually quoted for ISearchCatalogManager (...EF61, ...EF62). A
QueryInterface sweep of the AB310581-AC80-11D1-8DF3-00C04FB6EFxx family found
exactly one supported interface, ...EF50. Same story for the scope manager,
which answered to ...EF55.
Whether that is a documentation error or a build difference, hardcoding it makes
a tool that works on one machine. So lib/SearchApi.ps1 sweeps for the IIDs at
runtime. QueryInterface is read-only and cannot have side effects, so sweeping
is free of risk.
There is no type library, so the vtable layout is declared by hand. A wrong method index does not throw. It calls a different function with mismatched arguments. And the neighbours are not harmless:
ISearchCatalogManager slot 5 Reset()
ISearchCrawlScopeManager slot 13 RevertToDefaultScopes()
An off-by-one in either direction is data loss on someone's live machine.
So nothing mutates until three read-only probes pass:
GetIndexerVersionStrreturns a non-empty string.get_Namereturns exactly the catalog name that was passed toGetCatalog. This validates the candidate IID and the slot together.- A two-sided oracle:
IncludedInCrawlScopemust returntruefor a path that must be in scope andfalsefor one that must not.
The third point is the important one. A one-sided check passes by accident: a
misidentified slot returning some nonzero garbage looks like true. It cannot be
right about both directions by luck. ExplorerTune uses the user's Desktop (in
scope by default on every Windows install) and %WINDIR% (excluded by default on
every Windows install), both from the shipped default rules.
If any probe disagrees, Validated stays false and every mutating method throws
instead of running. That is the intended behaviour, not a bug — if you see it,
please open an issue with the printed report and your build number.
Worth stating plainly because it costs an afternoon otherwise. PowerShell
dispatches COM through IDispatch. These interfaces do not implement it, so a
[ComImport] cast handed back to PowerShell arrives as a bare
System.__ComObject with no methods on it:
Method invocation failed because [System.__ComObject]
does not contain a method named 'GetIndexerVersionStr'.
Every call has to happen inside the C# added by Add-Type, exposed as static
helpers. That is why lib/SearchApi.ps1 looks the way it does.
.\Set-SearchScope.ps1 -Status # catalog state, item count
.\Set-SearchScope.ps1 -Test 'C:\Some\Path' # is it in scope, reallyA scope change does not reindex instantly. -Status reports the catalog state
(Incremental crawl, Full crawl, Idle) and the item count, which is the
honest way to watch progress. SetupCompletedSuccessfully is not: the service
sets that flag back to 1 itself, so reading it back tells you nothing.