diff --git a/.github/requirements-semgrep.txt b/.github/requirements-semgrep.txt
index 136b999..32359a9 100644
--- a/.github/requirements-semgrep.txt
+++ b/.github/requirements-semgrep.txt
@@ -12,23 +12,30 @@
# release turning a green branch red on a commit that touched nothing teaches everybody to
# ignore the check.
#
-# THIS EXACT VERSION IS THE ONE THE GATE'S NUMBERS WERE MEASURED WITH. On 2026-09-22, against
-# commit 693bced exported to a clean directory, semgrep 1.177.0 with `--config p/default` ran
-# 131 rules over 301 files and reported zero findings and zero errors at level error. Those
-# numbers are quoted in .github/scripts/semgrep_gate.py, so moving this line means measuring
-# again rather than assuming the report will look the same.
+# THE GATE'S NUMBERS ARE MEASURED WITH THE VERSION BELOW. Measured again on 2026-10-14, when the
+# version moved from 1.178.0 to 1.180.0, the newest stable release on the package index that day,
+# with the pull request that brought snapshots to the window. That pull request's own Semgrep run,
+# on the merge of commit c8ea3cb into main with `--config p/default`: semgrep ran 374 rules over
+# 399 files and reported zero findings, and the gate read zero errors at level error and 419 rules.
+# The two rule counts are one run read two ways - 374 is semgrep's own summary, 419 the timing
+# list in the report, which is what the gate counts. The run on the commit before, d39889d, gave
+# the same numbers. Before them the numbers here were from 1.177.0 on 2026-09-22 (131 rules, 301
+# files), and 1.178.0 was never measured. Moving this line means measuring again rather than
+# assuming the report will look the same.
#
-# 301 OF 516 FILES, AND THAT IS NOT A FAULT. Semgrep's own default ignore patterns exclude
-# tests/, which is 212 of this repository's files. So a healthy run of this tree reads src/,
-# site/, .github/ and the root - about three hundred files - and a collapse to a handful is the
-# shape of a scan that ran somewhere else. The gate prints the number on every run for exactly
-# that reason.
+# 399 OF 743 FILES, AND THAT IS NOT A FAULT. Semgrep's own default ignore patterns exclude
+# tests/, which is 336 of this repository's files, one picture is skipped for being over 1 MB, and
+# the seven other pictures are not among the 399 either - that last part is the arithmetic of the
+# count, not a line semgrep prints. So a healthy run of this tree reads src/, site/, .github/,
+# packaging/ and the root - about four hundred files - and a collapse to a handful is the shape of
+# a scan that ran somewhere else. The gate prints the number on every run for exactly that reason.
#
-# Licence, read on 2026-09-22 from this exact version's own metadata rather than recalled. The
+# Licence, read on 2026-09-22 from that version's own metadata rather than recalled, and read again
+# on 2026-10-14 from 1.180.0's, which says the same. The
# package index reports `license_expression` as LGPL-2.1-or-later, with the older `license`
# field null and no licence classifier - which is the modern shape and worth saying, because a
# reader checking the field that used to hold this would find nothing and could conclude the
# licence was unstated. It analyses the source tree in CI and is never linked into anything this
# project ships, so it creates no distribution obligation - see THIRD-PARTY-NOTICES.md for what
# does.
-semgrep==1.178.0
+semgrep==1.180.0
diff --git a/.github/scripts/semgrep_gate.py b/.github/scripts/semgrep_gate.py
index 7ed18c5..ce35a76 100644
--- a/.github/scripts/semgrep_gate.py
+++ b/.github/scripts/semgrep_gate.py
@@ -45,8 +45,10 @@
A scan error blocks too. A rule that failed to run is not a rule that found
nothing, and "the scan was green" must never mean "the scan did not happen".
-Only entries at level "error" count: a clean run of this repository carries four
-entries at level "warn", and none of them is a reason to stop anybody.
+Only entries at level "error" count: a clean run of this repository carried four
+entries at level "warn" with 1.177.0 on 2026-09-22, and none of them is a reason
+to stop anybody. That count was not taken again with 1.180.0, because this gate
+prints only the level it blocks on.
A scan that read too few files blocks as well, and so does one carrying too few
rules. Both numbers are printed on every run rather than only when they fire.
@@ -62,17 +64,24 @@
What a healthy run of THIS repository looks like
-------------------------------------------------
-Measured 2026-09-22 against commit 693bced, exported to a clean directory so
-that exactly the 516 files a checkout carries were present, semgrep 1.177.0 with
-`--config p/default`:
+Measured 2026-10-14 by this repository's own Semgrep run on pull request 61,
+the merge of commit c8ea3cb into main, semgrep 1.180.0 with `--config p/default`.
+This gate's last line on that run:
- 131 rules, 301 files, 0 findings, 0 errors at level error, 4 at level warn
+ semgrep: 0 blocking, 0 other, 0 scan error(s), 399 file(s) scanned, 419 rule(s)
-301 rather than 516 because semgrep's own default ignore patterns exclude
-tests/, which is 212 of this repository's files. That is not a fault and not a
-setting of ours - it is the tool's default, and it is worth knowing before
-somebody reads "301" as a broken scan. The files that ARE read are src/, site/,
-.github/ and the root.
+Semgrep's own summary on the same run says 374 rules. Both are true - the gate
+counts the timing list in the report, which is the longer of the two.
+
+399 rather than the 743 files a checkout carries, because semgrep's own default
+ignore patterns exclude tests/, which is 336 of them, and the eight pictures are
+not read either. That is not a fault and not a setting of ours - it is the
+tool's default, and it is worth knowing before somebody reads "399" as a broken
+scan. The files that ARE read are src/, site/, .github/, packaging/ and the root.
+
+The first measurement, 2026-09-22 with 1.177.0 against commit 693bced exported
+to a clean directory, was 131 rules over 301 files - before the two Python
+scripts arrived, which the floors below come back to.
Findings decided once and recorded
-----------------------------------
@@ -100,9 +109,15 @@
# a gate that goes red for a legitimate reason is a gate people learn to bypass. Measured on
# this repository:
#
-# files 301 (before the Python scripts existed), 306, 309 - it tracks the tree
+# files 301 (before the Python scripts existed), 306, 309, 399 - it tracks the tree
# rules 131, 374, 419 - it tracks WHICH LANGUAGES ARE IN THE TREE, not the ruleset alone.
-# Two .py files took it from 131 to 374 by pulling in Python's rules.
+# Two .py files took it from 131 to 374 by pulling in Python's rules. On 2026-10-14,
+# with 1.180.0, ONE run said both 374 (semgrep's summary) and 419 (the timing list
+# this gate counts), so the last two may be one run read two ways - the earlier runs
+# did not keep both, and that is not settled.
+#
+# Both floors were checked against that run: 399 files over 200, 419 rules over 50. Still floors
+# against a collapse, far under a healthy run on purpose.
#
# So these refuse a collapse and say nothing about a drift of tens. Raising them is a deliberate
# act that means measuring again, exactly like the pinned scanner version.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf1802a..41545bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,32 @@ is not part of this repository.
## [Unreleased]
+### Added
+
+- Snapshots in the window. A "Snapshots..." button beside Export... offers "Take snapshot...", which
+ writes this machine to a file you choose - the same file `bws snapshot create` writes, readable by
+ either - and "Compare with this machine...", which reads a snapshot and shows what changed since it
+ in place of the list: one row per field that differs, the entries added and removed, and above
+ the table everything that makes the comparison less than exact, such as a snapshot taken on
+ another machine or without administrator rights. "Back to the list" or Escape returns. Nothing in
+ either changes a service, and a file at the chosen path that is not a snapshot is refused and left
+ as it was. A value read from a snapshot shows a control or text direction character as its code
+ point, as the command line does.
+
+### Changed
+
+- A comparison no longer lists, entry by entry, a field Windows refused to both sides. On a real
+ machine a few service descriptions point at a language file that is not there, and Windows refuses
+ them to every reader - so every comparison listed the same entries as "not fully compared" while
+ nothing about them had changed. Such a field is now one line among the caveats, naming the entries,
+ and a snapshot compared with a copy of itself says "No differences." again. In `--json` the field
+ and its entries are in a new `refusedByBoth` list at the end of the document, and those entries are
+ no longer in `notFullyCompared`. A field one side holds a value for and the other does not is still
+ listed against its entry, and the sentence there now says "holds no value for" rather than "never
+ read".
+- The window cannot be made narrower than 1000 points, up from 900, so the new button fits in the
+ bar of actions.
+
### Fixed
- `bws kill`, and Force stop in the window, no longer announce that they end a service's process
diff --git a/README.md b/README.md
index a396313..6fe6c3e 100644
--- a/README.md
+++ b/README.md
@@ -344,8 +344,10 @@ A tool that quietly fails to cover something is worse than one that says what it
**Not built yet - the second half of the audit story:**
-- **Snapshots are on the command line only.** The window shows, searches and changes, and it does
- not take or compare snapshots. Use `bws snapshot` beside it.
+- **Comparing two snapshot files is on the command line only.** The window takes a snapshot and
+ compares one with this machine, and `bws snapshot diff EARLIER LATER` compares two files - from two
+ machines, or from two moments. The window does not take a note with a snapshot either - `--note`
+ does.
- **No stock Windows baseline.** The opening screen has a place for *how many of these are not
from a clean install* and says it is not counted yet rather than showing a number it cannot
stand behind.
@@ -355,7 +357,8 @@ A tool that quietly fails to cover something is worse than one that says what it
by script from the commands the diff shows you.
- **No creating, deleting or repointing a service.** The launch path is read and reported, and it
is not edited.
-- **No recovery actions.** Not read, not shown, not searchable.
+- **No recovery actions in the list.** They are read before a process is ended, so a plan can warn
+ that Windows will start a service again, and they are not shown, searchable or editable.
**By design:**
@@ -563,8 +566,11 @@ by another program, which it then says, leaves alone and saves nothing over. A r
every operation before offering it, and copies the name or
everything about the entry, display name and description included. *Export...* writes the rows on screen, in the columns
you have on and the order you sorted them into, to a CSV file separated the way your Windows region
-settings separate lists, so a double click opens it in columns. Ctrl+C over the list copies
-everything about the chosen entry. Escape backs out of the innermost thing first. *Donate*, at the
+settings separate lists, so a double click opens it in columns. *Snapshots...* beside it takes a
+snapshot of the machine to a file, the same file `bws snapshot create` writes, or compares one with
+the machine as it is now and shows what changed in place of the list, one row per field. Ctrl+C
+over the list copies everything about the chosen entry. Escape backs out of the innermost thing
+first. *Donate*, at the
right end of the row above the search box, opens the project's support page in your browser - and
in a window running as administrator it asks the desktop to open it, so the browser does not get
those rights.
@@ -627,7 +633,8 @@ item are the plan. Until then a snapshot at deployment and a diff against the ma
**Working end to end:** the window and the command line over the same engine, the query language,
plans with preview, cascade, refusal and the way back, stop, start, restart, force stop, force
-restart and start type, snapshots and diffs between files and against the live machine, signatures,
+restart and start type, snapshots and diffs between files and against the live machine - in the
+window too, against the live machine - signatures,
privileges, security descriptors, triggers, memory, per-user folding, keyboard and screen reader in
the window, CSV export, JSON and exit codes on the command line.
diff --git a/src/Bws.Cli/DiffJson.cs b/src/Bws.Cli/DiffJson.cs
index c0a696e..b93a563 100644
--- a/src/Bws.Cli/DiffJson.cs
+++ b/src/Bws.Cli/DiffJson.cs
@@ -56,7 +56,8 @@ [.. diff.Uncertain.Select(Presence)],
[.. diff.Changed.Select(Changed)],
[.. diff.NotFullyCompared.Select(Changed)],
diff.NeitherRead,
- new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later)),
+ new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later),
+ [.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))]),
Options);
private static PresenceDocument Presence(EntryPresence entry) =>
@@ -86,6 +87,12 @@ private static ChangedDocument Changed(ChangedEntry entry) =>
/// Per-user session copies neither side was compared on, counted per side. Added 2026-09-30
/// (D-1) - an addition, so nothing a script already reads changes.
///
+ ///
+ /// Fields Windows refused to both snapshots, each with the entries it refused it on. Added
+ /// 2026-10-14 (backlog 559, owner's decision) and LAST, so the document only grew at its end.
+ /// What moved, said for whoever reads : an entry whose
+ /// only uncompared field was refused to both sides is no longer there - it is named here instead.
+ ///
private sealed record DiffDocument(
bool Differs,
CaveatsDocument Caveats,
@@ -95,7 +102,10 @@ private sealed record DiffDocument(
IReadOnlyList Changed,
IReadOnlyList NotFullyCompared,
IReadOnlyList NeitherRead,
- LeftOutDocument InstancesLeftOut);
+ LeftOutDocument InstancesLeftOut,
+ IReadOnlyList RefusedByBoth);
+
+ private sealed record RefusedDocument(string Field, IReadOnlyList Entries);
///
/// Metadata one of the two files does not carry, as the field names the file uses - a
diff --git a/src/Bws.Cli/DiffText.cs b/src/Bws.Cli/DiffText.cs
index 2633c35..bf0e5a6 100644
--- a/src/Bws.Cli/DiffText.cs
+++ b/src/Bws.Cli/DiffText.cs
@@ -44,8 +44,8 @@ internal static string Render(SnapshotDiff diff)
Changed(text, "cli.diff.changed", diff.Changed);
// Also its own heading, for the same reason one level down. These entries have
- // nothing different about them - one of the two snapshots simply never read a field,
- // so nobody can say either way. Filed under "changed" they read as drift that is not
+ // nothing different about them - one of the two snapshots holds no value for a field,
+ // so nobody can say either way. A field refused to BOTH is not here but in the caveats. Filed under "changed" they read as drift that is not
// there, which is how the first version of this printed "5 changed, 0 differences".
Changed(text, "cli.diff.notFullyCompared", diff.NotFullyCompared);
@@ -80,7 +80,11 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff)
(diff.LeftOut.Any, () => Texts.Of("cli.diff.caveat.instancesLeftOut", diff.LeftOut.Earlier, diff.LeftOut.Later))
];
- var said = lines.Where(line => line.Says).Select(line => line.Line()).ToList();
+ var said = lines
+ .Where(line => line.Says)
+ .Select(line => line.Line())
+ .Concat(diff.RefusedByBoth.Select(Refused))
+ .ToList();
foreach (var line in said)
{
@@ -93,6 +97,17 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff)
}
}
+ ///
+ /// A field Windows refused to both snapshots, said once with the entries it was refused on -
+ /// backlog 559, owner's decision of 2026-10-14. Until then each of those entries stood under
+ /// "not fully compared", so this machine compared with itself a minute later printed ten of
+ /// them every time.
+ ///
+ private static string Refused(RefusedField field) =>
+ field.Entries.Count == 1
+ ? Texts.Of("cli.diff.caveat.refusedByBoth.one", field.Field, field.Entries.Count, string.Join(", ", field.Entries))
+ : Texts.Of("cli.diff.caveat.refusedByBoth.many", field.Field, field.Entries.Count, string.Join(", ", field.Entries));
+
private static void Presence(StringBuilder text, string heading, IReadOnlyList entries)
{
if (entries.Count == 0)
diff --git a/src/Bws.Cli/Program.cs b/src/Bws.Cli/Program.cs
index c2e1af1..91b481d 100644
--- a/src/Bws.Cli/Program.cs
+++ b/src/Bws.Cli/Program.cs
@@ -135,20 +135,12 @@
if (options.Live)
{
- // Signatures and hashes are read here for the same reason snapshot create reads
- // them: the file on the other side has them. Comparing against a reading that
- // skipped them would mark every entry as "one side never read this", which is
- // 810 admissions and no answer.
- var before2 = stopwatch.ElapsedMilliseconds;
- entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths));
- inspected = stopwatch.ElapsedMilliseconds - before2;
-
- // The same third family, and for the same sentence: the file on the other side has
- // it. Left out, every entry lands in "neitherRead" - which is not a difference, so a
- // comparison reports none while the field goes uncompared.
- before2 = stopwatch.ElapsedMilliseconds;
- entries = RequiredByPass.Fill(entries, catalog!);
- asked = stopwatch.ElapsedMilliseconds - before2;
+ // Signatures, hashes and who depends on each entry are read here for the same reason
+ // snapshot create reads them: the file on the other side has them. Comparing against a
+ // reading that skipped them would mark every entry as "one side never read this",
+ // which is 810 admissions and no answer. One call since 2026-10-14, shared with
+ // snapshot create and the window - SnapshotPasses says why.
+ entries = SnapshotPasses.Fill(entries, catalog!, new WindowsBinaryInspector(networkPaths), out inspected, out asked);
after = Snapshot.Of(entries, note: null, new SystemClock());
}
@@ -224,20 +216,10 @@
return ExitCode.Usage;
}
- // Signatures and the hash are read every time here, unlike in a listing. A snapshot
- // is taken deliberately and kept for months, so being comparable against the next
- // one matters more than the several seconds it costs - and a snapshot missing them
- // would compare against one that has them as though the machine had changed.
- var before = stopwatch.ElapsedMilliseconds;
- entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths));
- inspected = stopwatch.ElapsedMilliseconds - before;
-
- // AND WHO DEPENDS ON EACH ENTRY, EVERY TIME, on the argument above and for a small fraction
- // of the price - RequiredByPass carries the measurement. No switch here: one taken without it
- // would compare against one that has it as though every service had lost its dependents.
- before = stopwatch.ElapsedMilliseconds;
- entries = RequiredByPass.Fill(entries, catalog!);
- asked = stopwatch.ElapsedMilliseconds - before;
+ // Signatures, the hash and who depends on each entry are read every time here, unlike in a
+ // listing - SnapshotPasses carries the argument, and since 2026-10-14 the one place it is
+ // read for the command line and the window alike.
+ entries = SnapshotPasses.Fill(entries, catalog!, new WindowsBinaryInspector(networkPaths), out inspected, out asked);
var snapshot = Snapshot.Of(entries, options.Note, new SystemClock());
var target = SnapshotFiles.Target(options.Path, snapshot.Metadata);
diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json
index 854559a..b3f8fa0 100644
--- a/src/Bws.Cli/Resources/cli.en.json
+++ b/src/Bws.Cli/Resources/cli.en.json
@@ -273,12 +273,12 @@
"cli.diff.entry": " {0} {1}",
"cli.diff.field.configuration": " {0}: {1} -> {2}",
"cli.diff.field.state": " {0}: {1} -> {2} (running state, not configuration)",
- "cli.diff.field.incomparable": " {0}: not compared, one of the two snapshots never read it",
+ "cli.diff.field.incomparable": " {0}: not compared, one of the two snapshots holds no value for it",
"cli.diff.nothing": "nothing",
"cli.diff.summary": "Added {0}, removed {1}, changed {2}. Fields differing: {3} in configuration, {4} in running state.",
"cli.diff.summaryIncomplete": "Not a complete comparison:",
- "cli.diff.incomplete.neverRead.many": " {0} entries carry a field one snapshot never read.",
- "cli.diff.incomplete.neverRead.one": " {0} entry carries a field one snapshot never read.",
+ "cli.diff.incomplete.neverRead.many": " {0} entries carry a field one snapshot holds no value for.",
+ "cli.diff.incomplete.neverRead.one": " {0} entry carries a field one snapshot holds no value for.",
"cli.diff.incomplete.seenByOne.many": " {0} entries only one of the two could see.",
"cli.diff.incomplete.seenByOne.one": " {0} entry only one of the two could see.",
"cli.diff.caveat.elevation": "The two snapshots were taken at different privilege levels. The one without administrator rights holds fewer entries, so what is missing from it may never have been removed.",
@@ -291,6 +291,8 @@
"cli.diff.caveat.notKnown": "At least one of the snapshots does not record these - it was written before they were, or the machine would not say - so they could not be checked: {0}",
"cli.diff.caveat.instancesLeftOut": "Per-user session copies come and go with the people signed in, so they are not compared - earlier snapshot: {0}, later snapshot: {1}.",
"cli.diff.caveat.neitherRead": "Neither snapshot holds these fields, so no entry was compared on them: {0}",
+ "cli.diff.caveat.refusedByBoth.many": "Windows would not give {0} to either snapshot for {1} entries, so they were not compared on it: {2}",
+ "cli.diff.caveat.refusedByBoth.one": "Windows would not give {0} to either snapshot for {1} entry, so it was not compared on it: {2}",
"cli.info.timingCompared": "Read and compared two snapshots in {0} ms.",
"cli.cell.megabytes": "{0} MB",
diff --git a/src/Bws.Cli/SnapshotFiles.cs b/src/Bws.Cli/SnapshotFiles.cs
index 21b8884..35e0bd9 100644
--- a/src/Bws.Cli/SnapshotFiles.cs
+++ b/src/Bws.Cli/SnapshotFiles.cs
@@ -1,308 +1,70 @@
using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
-using System.Text;
using Bws.Core.Snapshots;
namespace Bws.Cli;
///
-/// The snapshot as a file on somebody's disk: where it goes when they did not say, and what
-/// comes back when they point at one.
+/// The snapshot as a file on somebody's disk, in this front end's words and exit codes.
///
/// Moved out of Program.cs on 2026-08-03 because the size ratchet said so, for the fifth
-/// time. Telling a mistyped path apart from a disk that went away pushed that file fourteen
-/// lines past a ceiling that may only ever go down, and the ceiling exists so that adding to the
-/// longest file starts with looking for a seam.
+/// time. The seam is between the run and the disk. What stays behind reads the command line,
+/// chooses the verb and produces the document. What is here turns whatever is at the other end of a
+/// path into a snapshot or into a sentence and a code.
///
-/// The seam is between the run and the disk. What stays behind reads the command line, chooses
-/// the verb and produces the document. What is here touches a path: it works one out when nobody
-/// gave one, and it turns whatever is at the other end into a snapshot or into a sentence and a
-/// code. Execution took the other half of the same file for the same reason, on the same
-/// principle - the part that waits.
+/// THE RULES THEMSELVES LEFT FOR THE CORE ON 2026-10-14, when the window began taking and
+/// comparing snapshots (). The window references the core and nothing
+/// else, and two copies of "only a snapshot is ever replaced" would answer the same file two ways the
+/// first time one was changed. What stayed here is what only the command line has: the sentences on
+/// standard error and the code a run ends with. Every sentence and every code is the one this file
+/// produced before the move - the faults were drawn along the lines the old catches already drew.
///
internal static class SnapshotFiles
{
- ///
- /// The largest a file may be before this build refuses to treat it as a snapshot.
- ///
- /// Sixty four megabytes, against a measured 1.009 MB for this machine's 799 entries - so about
- /// fifty thousand entries, which no Windows installation has. The number refuses files that are
- /// not snapshots rather than snapshots that are large, and that is the whole of its job.
- ///
- private const long Biggest = 64L * 1024 * 1024;
-
- ///
- /// Where the snapshot goes when nobody said.
- ///
- /// The machine and the moment, in the directory the person is standing in. E1's own example
- /// writes a snapshot without naming a file, so a name has to be worked out - and it has to
- /// be one somebody can recognise weeks later among a dozen others, which rules out anything
- /// clever. Sorted by name is sorted by time, because the stamp runs from the largest unit
- /// down.
- ///
- /// Not a directory of our own choosing. ADR-18 says the tool does not invent directories,
- /// and a file appearing somewhere in a profile is a file nobody finds.
- ///
- /// The stamp is written with an invariant culture, and until 2026-08-03 it was written
- /// with the machine's. An interpolated hole formats with CurrentCulture, and a
- /// culture carries a calendar - so the same moment produced these names:
- ///
- ///
- /// pl-PL, en-US, ja-JP bws-snapshot-MACHINE-20260803-194500.json
- /// th-TH bws-snapshot-MACHINE-25690803-194500.json Buddhist year
- /// ar-SA bws-snapshot-MACHINE-14480220-194500.json Hijri
- /// fa-IR bws-snapshot-MACHINE-14050512-194500.json Persian
- ///
- ///
- /// The timestamp INSIDE the file is unaffected - the serialiser writes ISO 8601 whatever the
- /// machine thinks - so on those three systems the name of the file and the date in it said
- /// different things, and the sentence above about sorting by name was false. The
- /// quarantine name two files away had this right from the day it was written, which is what
- /// makes this an omission rather than a decision.
- ///
+ /// Where the snapshot goes when nobody said - see .
internal static string Target(string given, SnapshotMetadata metadata) =>
- given.Length > 0
- ? given
- : "bws-snapshot-" + metadata.Machine + "-"
- + metadata.TakenAt.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".json";
+ SnapshotOnDisk.Target(given, metadata);
///
- /// Whether this file may be written over.
- ///
- /// Nothing may replace an existing file without being told to - that has held since
- /// 2026-08-02 for a named file and, since 2026-08-03, for the name this tool works out itself.
- ///
- /// AND ONLY A SNAPSHOT IS EVER REPLACED, WITH --force OR WITHOUT IT, SINCE 2026-10-06 -
- /// security report S-8, owner's decision, a change to the frozen meaning of the switch. Until
- /// that day --force replaced any file at all and moved one it could not read into quarantine
- /// first. The file was not lost, but it was gone from its place: a mistyped variable in a script
- /// running as administrator or as SYSTEM took a configuration file away from the program that
- /// reads it, and the run ended with code 0. Now everything that is not a snapshot this build reads
- /// - another program's file, a snapshot from a schema this build does not know, one that is too
- /// large or will not open - is refused in words and left exactly where it was. For evidence that
- /// is better than quarantine ever was: the file is not even renamed.
- ///
- /// Asked before the signatures and again on the name that will be written, as it always
- /// was. The second ask is also what catches a file that became something else in the second the
- /// signatures took.
- ///
- /// A decision and nothing else - it moves nothing and writes nothing. That split is
- /// what makes this the single place the rule lives, and the mutation runner is what asked
- /// for it: the check was in two places, one early for the courtesy above and one late for
- /// the worked-out name, and taking either one away changed no outcome because the other
- /// still refused. Two gates for one rule means no single mutation can prove the rule, which
- /// the runner reported as MISSED and was right to.
+ /// Whether this file may be written over, and the sentence when it may not - the rule is
+ /// .
///
internal static bool MayWrite(string target, bool force, out string? refusal)
{
- refusal = null;
-
- // A FOLDER IS NEVER WRITTEN OVER, WITH OR WITHOUT --force, since 2026-09-30 - stability report
- // D-6, measured: `bws snapshot create ` spent 1545 ms reading signatures and
- // then ended with "Access to the path is denied." and code 2, because File.Exists says no for a
- // folder and the refusal came only from the write. Asked here, the courtesy check before the
- // expensive work says it in a second's less time and in words about what was typed. Writing
- // INTO the folder under a worked-out name would be a new feature rather than this repair.
- if (Directory.Exists(target))
- {
- refusal = Texts.Of("cli.snapshot.isFolder", target);
-
- return false;
- }
-
- if (!File.Exists(target))
- {
- return true;
- }
-
- if (WhyNotASnapshot(target) is { } why)
- {
- refusal = why + " " + Texts.Of("cli.snapshot.notReplaced");
-
- return false;
- }
-
- if (!force)
- {
- refusal = Texts.Of("cli.snapshot.fileExists", target);
-
- return false;
- }
-
- return true;
- }
-
- ///
- /// Why what is already at this path is not a snapshot this build reads, or nothing when it is one.
- ///
- /// The same four sentences says about a file somebody points
- /// snapshot diff at, because it is the same question about the same kind of file: too
- /// large to be one, not UTF-8, could not be read, or read and not a snapshot - with the reason the
- /// reader gave, which is what tells somebody holding a snapshot from a newer build why theirs is
- /// refused.
- ///
- /// THE SIZE IS ASKED BEFORE A BYTE IS READ, and until 2026-10-06 it was not. The question
- /// was asked by reading the whole file into one string - measured that day with the shipped 0.3.0:
- /// snapshot create over a 1.5 GB file with --force ended after 4045 ms with code 1 and "Insufficient
- /// memory to continue the execution of the program." had the ceiling since
- /// backlog 308(c), and this read beside it did not.
- ///
- private static string? WhyNotASnapshot(string path)
- {
- try
- {
- if (new FileInfo(path).Length > Biggest)
- {
- return Texts.Of("cli.diff.tooBig", path, Biggest / (1024 * 1024));
- }
-
- return SnapshotJson.TryRead(ReadText(path), out _, out var failure)
- ? null
- : Texts.Of("cli.diff.notASnapshot", path, failure ?? string.Empty);
- }
-
- // Ahead of the clause below for the reason Load gives: a decoder failure IS an ArgumentException.
- catch (DecoderFallbackException)
- {
- return Texts.Of("cli.diff.notUtf8", path);
- }
- catch (Exception unreadable) when (unreadable is IOException or UnauthorizedAccessException or ArgumentException)
+ refusal = SnapshotOnDisk.WhyNotWritten(target, force) switch
{
- return Texts.Of("cli.diff.cannotRead", path, unreadable.Message);
- }
- }
-
- ///
- /// The bytes at a path, as text, or nothing at all.
- ///
- /// A decoder that refuses, and until 2026-08-03 this was File.ReadAllText, which
- /// substitutes. That call honours a byte order mark and otherwise assumes UTF-8, and when
- /// the bytes are not UTF-8 it puts a replacement character in and carries on without a word.
- ///
- /// MEASURED on the same snapshot re-encoded four ways and compared against itself:
- ///
- ///
- /// UTF-8 No differences.
- /// UTF-8 with a mark No differences.
- /// UTF-16 with a mark No differences.
- /// Windows-1250 Changed (297)
- ///
- ///
- /// Two hundred and ninety seven entries reported as changed, and not one of them had.
- /// It takes one person opening a snapshot in an editor set to the machine's code page and
- /// saving it - and with --exit-code that is a pipeline failing over drift that does not
- /// exist. In a tool whose whole purpose is telling real drift from noise, silently guessing at
- /// bytes is the one thing it may not do.
- ///
- /// The mark is still honoured, so a snapshot saved as UTF-16 by something else still reads.
- /// Only bytes that are not any of those come back as a refusal.
- ///
- /// The bytes are not text this build can read.
- private static string ReadText(string path)
- {
- using var stream = File.OpenRead(path);
- using var reader = new StreamReader(
- stream,
- new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true),
- detectEncodingFromByteOrderMarks: true);
+ null => null,
+ { Fault: DiskFault.IsFolder } => Texts.Of("cli.snapshot.isFolder", target),
+ { Fault: DiskFault.AlreadyThere } => Texts.Of("cli.snapshot.fileExists", target),
+ var other => Said(other) + " " + Texts.Of("cli.snapshot.notReplaced")
+ };
- return reader.ReadToEnd();
+ return refusal is null;
}
///
/// Reads one snapshot from disk, or says what is wrong with what was pointed at.
- ///
- /// Deliberately narrow catches rather than a broad one. The ways a path can be wrong are a
- /// list somebody can finish - it is not there, it is a directory, it cannot be opened, it is
- /// not spellable - which is exactly the argument the broad catches in this project are
- /// allowed by, run backwards.
///
///
- /// How the run should end if this returns false. Its own answer rather than a code the
- /// caller assumes, because the two reasons a read fails are not the same person's fault.
+ /// How the run should end if this returns false. Its own answer rather than a code the caller
+ /// assumes, because the two reasons a read fails are not the same person's fault: a file that is
+ /// not there, a path that cannot be spelled, bytes that are not UTF-8 and a file that is not a
+ /// snapshot are what somebody pointed at (usage), and a disk that went away, a file another
+ /// process holds or a folder this account may not enter are the machine failing (runtime).
///
internal static bool Load(string path, out Snapshot? snapshot, out int code)
{
- snapshot = null;
code = ExitCode.Ok;
- string content;
-
- try
- {
- // ASKED HOW BIG IT IS BEFORE READING IT ALL, SINCE 2026-09-03 - backlog 308(c). The
- // reader below builds one string out of every byte, so a path pointing at something
- // enormous ends the process with an out of memory failure and a stack trace, which is
- // the one shape this tool's entry point cannot turn into a sentence worth reading.
- //
- // MEASURED RATHER THAN GUESSED: a snapshot of this machine, 799 entries, is 1.009 MB.
- // Sixty four megabytes is therefore about fifty thousand entries, which no Windows
- // installation has - so this refuses files that are not snapshots rather than
- // snapshots that are large.
- //
- // A file that is not there falls through to the reader below, which produces the
- // sentence about a path somebody got wrong. This clause only ever speaks about a file
- // that exists.
- if (File.Exists(path) && new FileInfo(path).Length > Biggest)
- {
- Console.Error.WriteLine(Texts.Of("cli.diff.tooBig", path, Biggest / (1024 * 1024)));
- code = ExitCode.Usage;
-
- return false;
- }
-
- content = ReadText(path);
- }
-
- // AHEAD OF THE TWO BELOW, and it has to be: a decoder failure IS an ArgumentException, so
- // the clause that calls a mistyped path a mistake would otherwise swallow it and blame
- // the wrong thing. This is not a path somebody got wrong - it is a file that exists,
- // opened cleanly, and holds bytes this build will not guess at.
- catch (DecoderFallbackException)
- {
- Console.Error.WriteLine(Texts.Of("cli.diff.notUtf8", path));
- code = ExitCode.Usage;
-
- return false;
- }
-
- // WHOSE FAULT IT WAS DECIDES THE CODE, and until 2026-08-03 every one of these was code 2 -
- // the one this tool reserves for what somebody typed wrongly. A file that is not there and a
- // path that cannot be spelled are indeed that. A disk that went away mid-read, a file another
- // process is holding open and a directory this account may not enter are not: they are the
- // machine failing, and calling them a typo teaches a monitor to ignore the code that says so.
- //
- // Step 5 of docs/12 asks exactly this question - can a script tell my fault from yours by
- // this number - and here the answer was no. `audit.ps1` is green either way, because it asks
- // whether a code is IN the table rather than whether it is the RIGHT one.
- catch (Exception mistyped) when (mistyped is FileNotFoundException or DirectoryNotFoundException or ArgumentException)
- {
- Console.Error.WriteLine(Texts.Of("cli.diff.cannotRead", path, mistyped.Message));
- code = ExitCode.Usage;
-
- return false;
- }
- catch (Exception broke) when (broke is IOException or UnauthorizedAccessException)
- {
- Console.Error.WriteLine(Texts.Of("cli.diff.cannotRead", path, broke.Message));
- code = ExitCode.Runtime;
-
- return false;
- }
-
- if (SnapshotJson.TryRead(content, out snapshot, out var failure))
+ if (SnapshotOnDisk.TryLoad(path, out snapshot, out var refusal))
{
return true;
}
// Names the file. Two are being read and a message about neither of them would leave
- // somebody checking both. The reason is never null when the read failed, and saying so
- // out loud is cheaper than a nullable sentence in a message.
- Console.Error.WriteLine(Texts.Of("cli.diff.notASnapshot", path, failure ?? string.Empty));
+ // somebody checking both.
+ Console.Error.WriteLine(Said(refusal));
- // A file that is not a snapshot is what somebody pointed at, so it keeps the usage code.
- // The machine did its part here - it handed over every byte that was asked for.
- code = ExitCode.Usage;
+ code = refusal.Fault == DiskFault.Unreadable ? ExitCode.Runtime : ExitCode.Usage;
return false;
}
@@ -312,23 +74,18 @@ internal static bool Load(string path, out Snapshot? snapshot, out int code)
///
/// Here rather than beside the verb it serves, and the reason is the size ratchet.
/// Program.cs stood at 549 lines against a ceiling of 552 that may only ever go down, so the
- /// branch this needs would have reddened the build - which is the ratchet working, not
- /// getting in the way. This file already owns "turn a snapshot the person pointed at into
- /// either a document or a sentence and a code", and a comparison refusing one of its two
- /// sides is the same sentence arriving one step later.
- ///
- /// Unreachable through the command line today, and that is written down rather than left
- /// to be discovered. Both sides of a two-file comparison have been through
- /// SnapshotJson.TryRead, which refuses these documents already, and --live builds its
- /// side from what the manager handed over, which cannot hold one service twice. What this
- /// guards is the next caller - a window showing a comparison - and the cost of guarding it
- /// now is one branch.
+ /// branch this needs would have reddened the build. This file already owns "turn a snapshot the
+ /// person pointed at into either a document or a sentence and a code", and a comparison refusing
+ /// one of its two sides is the same sentence arriving one step later.
+ ///
+ /// Unreachable through the command line today, and that is written down rather than left to be
+ /// discovered. Both sides of a two-file comparison have been through
+ /// SnapshotJson.TryRead, which refuses these documents already, and --live builds its side
+ /// from what the manager handed over, which cannot hold one service twice.
///
///
- /// Annotated rather than left for the caller to assert with an exclamation mark. The compiler
- /// then knows what the return value already meant, and the alternative - telling it to be
- /// quiet twice at the call site - says the same thing while switching off the check that
- /// would notice if it stopped being true.
+ /// Annotated rather than left for the caller to assert with an exclamation mark. The compiler then
+ /// knows what the return value already meant.
///
internal static bool Compare(
Snapshot before, Snapshot after, [NotNullWhen(true)] out SnapshotDiff? difference, out int code)
@@ -348,4 +105,18 @@ internal static bool Compare(
return false;
}
+
+ ///
+ /// The sentence for a file that could not be read as a snapshot - the four the command line has
+ /// said since 2026-08-03, chosen by the fault rather than by the exception that used to carry it.
+ /// A file that is not there and one the machine would not hand over share a sentence, and differ
+ /// only in the code ends with.
+ ///
+ private static string Said(DiskRefusal refusal) => refusal.Fault switch
+ {
+ DiskFault.TooBig => Texts.Of("cli.diff.tooBig", refusal.Path, SnapshotOnDisk.Biggest / (1024 * 1024)),
+ DiskFault.NotUtf8 => Texts.Of("cli.diff.notUtf8", refusal.Path),
+ DiskFault.NotASnapshot => Texts.Of("cli.diff.notASnapshot", refusal.Path, refusal.Detail),
+ _ => Texts.Of("cli.diff.cannotRead", refusal.Path, refusal.Detail)
+ };
}
diff --git a/src/Bws.Cli/Texts.cs b/src/Bws.Cli/Texts.cs
index 0c93daf..0848db9 100644
--- a/src/Bws.Cli/Texts.cs
+++ b/src/Bws.Cli/Texts.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Reflection;
using System.Text.Json;
+using Bws.Core;
namespace Bws.Cli;
diff --git a/src/Bws.Cli/Printable.cs b/src/Bws.Core/Printable.cs
similarity index 79%
rename from src/Bws.Cli/Printable.cs
rename to src/Bws.Core/Printable.cs
index 03415aa..74d17a5 100644
--- a/src/Bws.Cli/Printable.cs
+++ b/src/Bws.Core/Printable.cs
@@ -2,10 +2,17 @@
using System.Globalization;
using System.Text;
-namespace Bws.Cli;
+namespace Bws.Core;
///
-/// A value from the machine or from a file, made safe to put in front of a terminal.
+/// A value from the machine or from a file, made safe to put in front of a terminal - and, since
+/// 2026-10-14, in front of a person reading somebody else's snapshot in the window.
+///
+/// In the core since that day, moved from the command line when the window began comparing
+/// snapshots (`docs/PROJEKT-DRUGA-POLOWA-AUDYTU-20261014.md`, W1). A window is not a terminal - no
+/// character here can move a cursor in it - but the half of this set that reorders text works the
+/// same in a cell as in a console: a file name in a crafted snapshot can show one extension and carry
+/// another. One rule for both front ends rather than a second list in the window that would drift.
///
/// Stability report C-4, owner's decision 2026-10-05. Names, paths, descriptions and the
/// values inside a snapshot reached the terminal exactly as they were. A tab or a line break in
@@ -24,12 +31,12 @@ namespace Bws.Cli;
/// Text only. A JSON document escapes all of these anyway, through AsciiJson. And it is
/// applied to VALUES, never to sentences: five templates of this tool break lines on purpose.
///
-internal static class Printable
+public static class Printable
{
private static readonly SearchValues Unprintable = SearchValues.Create(Characters());
/// The value with every character from the set above written out as its code point.
- internal static string Of(string value)
+ public static string Of(string value)
{
ArgumentNullException.ThrowIfNull(value);
diff --git a/src/Bws.Core/SnapshotPasses.cs b/src/Bws.Core/SnapshotPasses.cs
new file mode 100644
index 0000000..5fa9503
--- /dev/null
+++ b/src/Bws.Core/SnapshotPasses.cs
@@ -0,0 +1,39 @@
+using System.Diagnostics;
+
+namespace Bws.Core;
+
+///
+/// What a snapshot needs read on top of a listing: who signed each binary and who depends on each
+/// entry, each timed on its own.
+///
+/// One place since 2026-10-14, and the window is why. The command line read the two passes in
+/// two places - taking a snapshot and comparing one against the machine - and the window taking and
+/// comparing snapshots would have been the third copy of the same four lines. The third time a shape
+/// appears it becomes a name. A copy that forgot one pass would take snapshots that compare against
+/// the command line's as though every entry had lost its signature or its dependents, and nothing
+/// would say so.
+///
+/// Both passes, always, and no switch. A snapshot is taken deliberately and kept for months,
+/// so being comparable against the next one matters more than the second it costs (about 1.1-1.2 s
+/// for the whole snapshot over 801 entries on sixteen processors, measured 2026-10-14) - and one
+/// missing either family would compare against one that has it as though the machine had changed.
+///
+public static class SnapshotPasses
+{
+ /// The listing with signatures and dependents filled in, new records as every pass returns.
+ /// Milliseconds spent on the files, for the command line's --timing.
+ /// Milliseconds spent asking who depends on each entry.
+ public static IReadOnlyList Fill(
+ IReadOnlyList listed, IScmCatalog catalog, IBinaryInspector inspector, out long inspected, out long asked)
+ {
+ var started = Stopwatch.GetTimestamp();
+ var signed = SecondPass.Fill(listed, inspector);
+ inspected = (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
+
+ started = Stopwatch.GetTimestamp();
+ var answered = RequiredByPass.Fill(signed, catalog);
+ asked = (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds;
+
+ return answered;
+ }
+}
diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs b/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs
new file mode 100644
index 0000000..23a31c1
--- /dev/null
+++ b/src/Bws.Core/Snapshots/SnapshotDiff.Unseen.cs
@@ -0,0 +1,52 @@
+namespace Bws.Core.Snapshots;
+
+///
+/// What a comparison could not look at: fields one side refused, fields nobody asked about, and since
+/// 2026-10-14 fields refused to both sides - the three readings tells apart
+/// before it compares anything.
+///
+/// Its own file since that day, when the third kind arrived and the comparison stood over the
+/// share of its file ceiling the size guard counts as near. One subject, the unread, so one place.
+///
+public sealed partial record SnapshotDiff
+{
+ /// Fields this machine refused. A fact about permissions on this entry.
+ private static IEnumerable Refused(EntryDocument entry) =>
+ (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(Camel);
+
+ /// Fields nobody asked about on that run. A fact about the run, not the entry.
+ private static IEnumerable NotAsked(EntryDocument entry) =>
+ (entry.NotRead ?? []).Select(Camel);
+
+ ///
+ /// The field names inside "unreadable" and "notRead" are written the same way as the
+ /// fields they talk about. They come from the property names, so they arrive capitalised
+ /// and the keys beside them do not.
+ ///
+ private static string Camel(string name) =>
+ name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..];
+
+ ///
+ /// What a comparison could not look at and says once rather than against each entry - fields
+ /// neither snapshot asked about, and fields Windows refused to both. One value handed down the
+ /// walk instead of two collections, so the per-entry comparison keeps the four parameters it had.
+ ///
+ private sealed class Unseen
+ {
+ internal HashSet NeitherRead { get; } = new(StringComparer.Ordinal);
+
+ /// Field to entries, sorted by field so the document comes out the same every time.
+ internal SortedDictionary> RefusedByBoth { get; } = new(StringComparer.Ordinal);
+
+ internal void Refused(string field, string serviceName)
+ {
+ if (!RefusedByBoth.TryGetValue(field, out var entries))
+ {
+ entries = [];
+ RefusedByBoth.Add(field, entries);
+ }
+
+ entries.Add(serviceName);
+ }
+ }
+}
diff --git a/src/Bws.Core/Snapshots/SnapshotDiff.cs b/src/Bws.Core/Snapshots/SnapshotDiff.cs
index 1c2e4b7..016b6c6 100644
--- a/src/Bws.Core/Snapshots/SnapshotDiff.cs
+++ b/src/Bws.Core/Snapshots/SnapshotDiff.cs
@@ -28,9 +28,11 @@ public sealed record FieldDifference(string Field, DifferenceGroup Group, string
public sealed record EntryPresence(string ServiceName, string DisplayName);
///
-/// Fields that could not be compared because at least one side never read them. Named rather
-/// than dropped: silence about a field nobody could read is exactly what rule 8 forbids, and
-/// "we did not compare this" is a different sentence from "this did not change".
+/// Fields that could not be compared because one side holds no value for them - refused there, or
+/// never asked. Named rather than dropped: silence about a field nobody could read is exactly what
+/// rule 8 forbids, and "we did not compare this" is a different sentence from "this did not
+/// change". A field Windows refused to BOTH sides is not here since 2026-10-14 - it is in
+/// , said once for the comparison.
///
public sealed record ChangedEntry(
string ServiceName,
@@ -38,6 +40,19 @@ public sealed record ChangedEntry(
IReadOnlyList Differences,
IReadOnlyList Incomparable);
+///
+/// A field Windows refused to BOTH snapshots, and the entries it refused it on.
+///
+/// Its own shape since 2026-10-14 (backlog 559, owner's decision). Until then a field refused
+/// on both sides was named against each entry like a field only one side could read, so a machine
+/// compared with itself a minute later listed ten entries as "not fully compared" every time - their
+/// descriptions point at a resource file that is not there, and Windows says so to both snapshots.
+/// Nothing about those entries is uncertain in a way one of the two snapshots could have settled, so
+/// it is said once for the whole comparison, the way a field neither snapshot asked about is.
+///
+/// Service names, in the order the comparison walked them.
+public sealed record RefusedField(string Field, IReadOnlyList Entries);
+
///
/// What changed between two snapshots.
///
@@ -84,16 +99,22 @@ public sealed record ChangedEntry(
/// would be the same admission eight hundred times, the shape this project already met when a
/// listing threatened to answer "I do not know" about every entry it had.
///
+///
+/// Fields Windows refused to both snapshots, each with the entries it refused it on. Never part
+/// of and never counted by - see
+/// for why a refusal on both sides is said once rather than per entry.
+///
///
/// The per-user session copies neither side was compared on - see .
/// Matched by the role the type bits give an entry, never by the shape of its name.
///
-public sealed record SnapshotDiff(
+public sealed partial record SnapshotDiff(
IReadOnlyList Added,
IReadOnlyList Removed,
IReadOnlyList Changed,
IReadOnlyList NotFullyCompared,
IReadOnlyList NeitherRead,
+ IReadOnlyList RefusedByBoth,
IReadOnlyList Uncertain,
ComparisonCaveats Caveats,
InstancesLeftOut LeftOut)
@@ -238,13 +259,13 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after)
var uncertain = new List();
var changed = new List();
var partial = new List();
- var neitherRead = new HashSet(StringComparer.Ordinal);
+ var unseen = new Unseen();
foreach (var entry in Ordered(later))
{
if (left.TryGetValue(entry.ServiceName, out var was))
{
- var difference = Compare(was, entry, neitherRead, ignored);
+ var difference = Compare(was, entry, unseen, ignored);
if (difference is not null)
{
@@ -281,7 +302,8 @@ private static SnapshotDiff Between(Snapshot before, Snapshot after)
removed,
changed,
partial,
- [.. neitherRead.OrderBy(field => field, StringComparer.Ordinal)],
+ [.. unseen.NeitherRead.OrderBy(field => field, StringComparer.Ordinal)],
+ [.. unseen.RefusedByBoth.Select(pair => new RefusedField(pair.Key, pair.Value))],
[.. uncertain.OrderBy(entry => entry.ServiceName, StringComparer.Ordinal)],
caveats,
new InstancesLeftOut(leftOutEarlier, leftOutLater));
@@ -324,7 +346,7 @@ private static List Kept(IReadOnlyList entries, ou
/// says why - never listed as incomparable here, which would repeat that sentence on every row.
///
private static ChangedEntry? Compare(
- EntryDocument before, EntryDocument after, HashSet neitherRead, string[] ignored)
+ EntryDocument before, EntryDocument after, Unseen unseen, string[] ignored)
{
var was = SnapshotJson.Document(before);
var now = SnapshotJson.Document(after);
@@ -341,7 +363,14 @@ private static List Kept(IReadOnlyList entries, ou
// field this build does not read at all. Repeating it per entry would put the same
// sentence on every row - the "810 shrugs" this project already walked into once with
// triggers. Said once, for the whole comparison.
+ //
+ // Refused on BOTH sides is the third case and it is about the entry again, but not about the
+ // comparison being unable to settle it: neither snapshot holds a value, so no second look
+ // could have told a change from none. Said once for the whole comparison since 2026-10-14
+ // (backlog 559) - named per entry, it put the same ten entries under "not fully compared"
+ // every time this machine was compared with itself.
var refused = Refused(before).Union(Refused(after), StringComparer.Ordinal);
+ var refusedByBoth = Refused(before).Intersect(Refused(after), StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal);
var neither = NotAsked(before).Intersect(NotAsked(after), StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal);
var skip = NotAsked(before)
@@ -350,7 +379,7 @@ private static List Kept(IReadOnlyList entries, ou
.Union(refused, StringComparer.Ordinal)
.ToHashSet(StringComparer.Ordinal);
- neitherRead.UnionWith(neither);
+ unseen.NeitherRead.UnionWith(neither);
var differences = new List();
var incomparable = new List();
@@ -360,6 +389,14 @@ private static List Kept(IReadOnlyList entries, ou
.Where(field => Comparable(field) && !ignored.Contains(field, StringComparer.Ordinal))
.OrderBy(field => field, StringComparer.Ordinal))
{
+ // Refused to both is a part of skip, so it is asked first - and flat rather than nested,
+ // because the shape guard counts a third level here as a method climbing towards its ceiling.
+ if (refusedByBoth.Contains(field))
+ {
+ unseen.Refused(field, after.ServiceName);
+ continue;
+ }
+
if (skip.Contains(field))
{
incomparable.Add(field);
@@ -384,22 +421,6 @@ private static List Kept(IReadOnlyList entries, ou
: new ChangedEntry(after.ServiceName, after.DisplayName, differences, incomparable);
}
- /// Fields this machine refused. A fact about permissions on this entry.
- private static IEnumerable Refused(EntryDocument entry) =>
- (entry.Unreadable?.Keys ?? Enumerable.Empty()).Select(Camel);
-
- /// Fields nobody asked about on that run. A fact about the run, not the entry.
- private static IEnumerable NotAsked(EntryDocument entry) =>
- (entry.NotRead ?? []).Select(Camel);
-
- ///
- /// The field names inside "unreadable" and "notRead" are written the same way as the
- /// fields they talk about. They come from the property names, so they arrive capitalised
- /// and the keys beside them do not.
- ///
- private static string Camel(string name) =>
- name.Length == 0 ? name : char.ToLowerInvariant(name[0]) + name[1..];
-
private static bool Comparable(string field) =>
field != Identity && field != Unstable && !Bookkeeping.Contains(field, StringComparer.Ordinal);
diff --git a/src/Bws.Core/Snapshots/SnapshotOnDisk.cs b/src/Bws.Core/Snapshots/SnapshotOnDisk.cs
new file mode 100644
index 0000000..6ccd6e0
--- /dev/null
+++ b/src/Bws.Core/Snapshots/SnapshotOnDisk.cs
@@ -0,0 +1,290 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text;
+
+namespace Bws.Core.Snapshots;
+
+/// What is wrong with a path somebody pointed at as a snapshot, or asked one to be written to.
+public enum DiskFault
+{
+ /// Not there, or a path that cannot be spelled - what somebody typed.
+ Mistyped,
+
+ /// There, and the machine would not hand it over - a disk that went away, a lock, a permission.
+ Unreadable,
+
+ /// Larger than , so not something this build wrote.
+ TooBig,
+
+ /// Bytes that are not UTF-8, which this build refuses rather than guesses at.
+ NotUtf8,
+
+ /// Read cleanly and not a snapshot this build reads - the reader says why.
+ NotASnapshot,
+
+ /// A folder stands where a snapshot was to be written.
+ IsFolder,
+
+ /// A snapshot stands there, and nobody said it may be replaced.
+ AlreadyThere
+}
+
+///
+/// What was said underneath: Windows' own sentence for a read it refused, the reader's reason for a
+/// file that is not a snapshot. Empty where the fault says everything there is to say.
+///
+public sealed record DiskRefusal(DiskFault Fault, string Path, string Detail);
+
+///
+/// The snapshot as a file on somebody's disk: what it is called when nobody named it, whether a path
+/// may be written to, and what comes back when somebody points at one.
+///
+/// Moved here from the command line on 2026-10-14, when the window began taking and comparing
+/// snapshots (`docs/PROJEKT-DRUGA-POLOWA-AUDYTU-20261014.md`, W1). The window references the core
+/// and nothing else, so the alternative was a second copy of every rule below - the size ceiling,
+/// the refusal to guess at bytes, the folder, and S-8's "only a snapshot is ever replaced" - and two
+/// copies of a rule about somebody's evidence answer the same file two ways the first time one of
+/// them is changed. What did not move is the words: this says WHICH fault, and each front end says it
+/// in its own sentences.
+///
+public static class SnapshotOnDisk
+{
+ ///
+ /// The largest a file may be before this build refuses to treat it as a snapshot.
+ ///
+ /// Sixty four megabytes, against a measured 1.009 MB for this machine's 799 entries - so about
+ /// fifty thousand entries, which no Windows installation has. The number refuses files that are
+ /// not snapshots rather than snapshots that are large, and that is the whole of its job.
+ ///
+ public const long Biggest = 64L * 1024 * 1024;
+
+ ///
+ /// Where the snapshot goes when nobody said: the name given, or the machine and the moment.
+ ///
+ /// E1's own example writes a snapshot without naming a file, so a name has to be worked out - and
+ /// it has to be one somebody can recognise weeks later among a dozen others, which rules out
+ /// anything clever. Sorted by name is sorted by time, because the stamp runs from the largest unit
+ /// down.
+ ///
+ /// The stamp is written with an invariant culture, and until 2026-08-03 it was written with the
+ /// machine's. An interpolated hole formats with CurrentCulture, and a culture carries a
+ /// calendar - so the same moment produced these names:
+ ///
+ ///
+ /// pl-PL, en-US, ja-JP bws-snapshot-MACHINE-20260803-194500.json
+ /// th-TH bws-snapshot-MACHINE-25690803-194500.json Buddhist year
+ /// ar-SA bws-snapshot-MACHINE-14480220-194500.json Hijri
+ /// fa-IR bws-snapshot-MACHINE-14050512-194500.json Persian
+ ///
+ ///
+ /// The timestamp INSIDE the file is unaffected - the serialiser writes ISO 8601 whatever the
+ /// machine thinks - so on those three systems the name of the file and the date in it said
+ /// different things.
+ ///
+ public static string Target(string given, SnapshotMetadata metadata)
+ {
+ ArgumentNullException.ThrowIfNull(given);
+ ArgumentNullException.ThrowIfNull(metadata);
+
+ return given.Length > 0
+ ? given
+ : "bws-snapshot-" + metadata.Machine + "-"
+ + metadata.TakenAt.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + ".json";
+ }
+
+ ///
+ /// Why this path may not be written to, or nothing when it may.
+ ///
+ /// Nothing may replace an existing file without being told to - that has held since
+ /// 2026-08-02 for a named file and, since 2026-08-03, for the name this tool works out itself.
+ ///
+ /// AND ONLY A SNAPSHOT IS EVER REPLACED, TOLD OR NOT, SINCE 2026-10-06 - security report S-8,
+ /// owner's decision. Everything that is not a snapshot this build reads - another program's
+ /// file, a snapshot from a schema this build does not know, one that is too large or will not
+ /// open - is refused and left exactly where it was, not even renamed.
+ ///
+ /// A decision and nothing else - it moves nothing and writes nothing. That split is what
+ /// makes this the single place the rule lives, and the mutation runner asked for it: the check was
+ /// once in two places, and taking either one away changed no outcome because the other still
+ /// refused. Two gates for one rule means no single mutation can prove the rule.
+ ///
+ ///
+ /// Whether somebody said a snapshot standing there may go - --force on the command line,
+ /// the answer to "replace it?" in the window's save dialog.
+ ///
+ public static DiskRefusal? WhyNotWritten(string target, bool force)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+
+ // A FOLDER IS NEVER WRITTEN OVER, WITH OR WITHOUT --force, since 2026-09-30 - stability report
+ // D-6, measured: `bws snapshot create ` spent 1545 ms reading signatures and
+ // then ended with "Access to the path is denied." and code 2, because File.Exists says no for a
+ // folder and the refusal came only from the write.
+ if (Directory.Exists(target))
+ {
+ return new DiskRefusal(DiskFault.IsFolder, target, string.Empty);
+ }
+
+ if (!File.Exists(target))
+ {
+ return null;
+ }
+
+ if (WhyNotASnapshot(target) is { } why)
+ {
+ return why;
+ }
+
+ if (!force)
+ {
+ return new DiskRefusal(DiskFault.AlreadyThere, target, string.Empty);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Reads one snapshot from disk, or says what is wrong with what was pointed at.
+ ///
+ /// Deliberately narrow catches rather than a broad one. The ways a path can be wrong are a list
+ /// somebody can finish - it is not there, it is a directory, it cannot be opened, it is not
+ /// spellable - which is exactly the argument the broad catches in this project are allowed by,
+ /// run backwards.
+ ///
+ /// Whose fault it was is kept apart - against
+ /// - because the command line ends the two with different codes:
+ /// a disk that went away or a file another process holds is the machine failing, and calling it a
+ /// typo teaches a monitor to ignore the code that says so (docs/12, step 5).
+ ///
+ public static bool TryLoad(
+ string path, [NotNullWhen(true)] out Snapshot? snapshot, [NotNullWhen(false)] out DiskRefusal? refusal)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ snapshot = null;
+ refusal = null;
+
+ string content;
+
+ try
+ {
+ // ASKED HOW BIG IT IS BEFORE READING IT ALL, SINCE 2026-09-03 - backlog 308(c). The reader
+ // below builds one string out of every byte, so a path pointing at something enormous ends
+ // the process with an out of memory failure. A file that is not there falls through to the
+ // reader, which produces the sentence about a path somebody got wrong.
+ if (File.Exists(path) && new FileInfo(path).Length > Biggest)
+ {
+ refusal = new DiskRefusal(DiskFault.TooBig, path, string.Empty);
+
+ return false;
+ }
+
+ content = ReadText(path);
+ }
+
+ // AHEAD OF THE TWO BELOW, and it has to be: a decoder failure IS an ArgumentException, so the
+ // clause that calls a mistyped path a mistake would otherwise swallow it and blame the wrong
+ // thing.
+ catch (DecoderFallbackException)
+ {
+ refusal = new DiskRefusal(DiskFault.NotUtf8, path, string.Empty);
+
+ return false;
+ }
+ catch (Exception mistyped) when (mistyped is FileNotFoundException or DirectoryNotFoundException or ArgumentException)
+ {
+ refusal = new DiskRefusal(DiskFault.Mistyped, path, mistyped.Message);
+
+ return false;
+ }
+ catch (Exception broke) when (broke is IOException or UnauthorizedAccessException)
+ {
+ refusal = new DiskRefusal(DiskFault.Unreadable, path, broke.Message);
+
+ return false;
+ }
+
+ // The reader's own out value carries no promise about null, so the promise this method makes
+ // is kept here rather than asserted with an exclamation mark - a document it calls read and
+ // hands back as nothing would otherwise reach a caller the annotation told not to check.
+ if (SnapshotJson.TryRead(content, out var read, out var failure) && read is not null)
+ {
+ snapshot = read;
+
+ return true;
+ }
+
+ // The reason is never null when the read failed, and saying so out loud is cheaper than a
+ // nullable sentence in a message.
+ refusal = new DiskRefusal(DiskFault.NotASnapshot, path, failure ?? string.Empty);
+
+ return false;
+ }
+
+ ///
+ /// Why what is already at this path is not a snapshot this build reads, or nothing when it is one.
+ ///
+ /// The same faults names, because it is the same question about the
+ /// same kind of file - asked here of something standing where a snapshot is about to go.
+ ///
+ /// THE SIZE IS ASKED BEFORE A BYTE IS READ, and until 2026-10-06 it was not. Measured that
+ /// day with the shipped 0.3.0: snapshot create over a 1.5 GB file with --force ended after
+ /// 4045 ms with code 1 and "Insufficient memory to continue the execution of the program."
+ ///
+ private static DiskRefusal? WhyNotASnapshot(string path)
+ {
+ try
+ {
+ if (new FileInfo(path).Length > Biggest)
+ {
+ return new DiskRefusal(DiskFault.TooBig, path, string.Empty);
+ }
+
+ return SnapshotJson.TryRead(ReadText(path), out _, out var failure)
+ ? null
+ : new DiskRefusal(DiskFault.NotASnapshot, path, failure ?? string.Empty);
+ }
+
+ // Ahead of the clause below for the reason TryLoad gives: a decoder failure IS an ArgumentException.
+ catch (DecoderFallbackException)
+ {
+ return new DiskRefusal(DiskFault.NotUtf8, path, string.Empty);
+ }
+ catch (Exception unreadable) when (unreadable is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ return new DiskRefusal(DiskFault.Unreadable, path, unreadable.Message);
+ }
+ }
+
+ ///
+ /// The bytes at a path, as text, or nothing at all.
+ ///
+ /// A decoder that refuses, and until 2026-08-03 this was File.ReadAllText, which
+ /// substitutes. That call honours a byte order mark and otherwise assumes UTF-8, and when the
+ /// bytes are not UTF-8 it puts a replacement character in and carries on without a word.
+ ///
+ /// MEASURED on the same snapshot re-encoded four ways and compared against itself:
+ ///
+ ///
+ /// UTF-8 No differences.
+ /// UTF-8 with a mark No differences.
+ /// UTF-16 with a mark No differences.
+ /// Windows-1250 Changed (297)
+ ///
+ ///
+ /// Two hundred and ninety seven entries reported as changed, and not one of them had. The
+ /// mark is still honoured, so a snapshot saved as UTF-16 by something else still reads. Only bytes
+ /// that are not any of those come back as a refusal.
+ ///
+ /// The bytes are not text this build can read.
+ private static string ReadText(string path)
+ {
+ using var stream = File.OpenRead(path);
+ using var reader = new StreamReader(
+ stream,
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true),
+ detectEncodingFromByteOrderMarks: true);
+
+ return reader.ReadToEnd();
+ }
+}
diff --git a/src/Bws.Gui/ActionBar.xaml b/src/Bws.Gui/ActionBar.xaml
index 248d18b..a894dbc 100644
--- a/src/Bws.Gui/ActionBar.xaml
+++ b/src/Bws.Gui/ActionBar.xaml
@@ -164,6 +164,28 @@
Margin="{StaticResource MarginBetweenControls}"
Click="ExportAsked" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bws.Gui/DiffTable.xaml.cs b/src/Bws.Gui/DiffTable.xaml.cs
new file mode 100644
index 0000000..2b9c75f
--- /dev/null
+++ b/src/Bws.Gui/DiffTable.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows.Controls;
+
+namespace Bws.Gui;
+
+/// The table under a comparison, or the sentence in its place - the markup says the rest.
+public partial class DiffTable : UserControl
+{
+ public DiffTable()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Bws.Gui/DiffView.xaml b/src/Bws.Gui/DiffView.xaml
new file mode 100644
index 0000000..5e278be
--- /dev/null
+++ b/src/Bws.Gui/DiffView.xaml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bws.Gui/DiffView.xaml.cs b/src/Bws.Gui/DiffView.xaml.cs
new file mode 100644
index 0000000..3a7f796
--- /dev/null
+++ b/src/Bws.Gui/DiffView.xaml.cs
@@ -0,0 +1,24 @@
+using System.Windows;
+using System.Windows.Controls;
+
+namespace Bws.Gui;
+
+///
+/// What changed since a snapshot, in place of the list - the markup says the rest.
+///
+/// It asks and does not answer, the arrangement every part of this window uses: the way back
+/// is an event, and the window decides what the middle of it shows. Nothing here knows the list, the
+/// overview or the model exist.
+///
+public partial class DiffView : UserControl
+{
+ public DiffView()
+ {
+ InitializeComponent();
+ }
+
+ /// Somebody asked for the list back.
+ internal event EventHandler? Finished;
+
+ private void Back(object sender, RoutedEventArgs e) => Finished?.Invoke(this, EventArgs.Empty);
+}
diff --git a/src/Bws.Gui/MainWindow.Keyboard.cs b/src/Bws.Gui/MainWindow.Keyboard.cs
index e5dae6d..9f9732d 100644
--- a/src/Bws.Gui/MainWindow.Keyboard.cs
+++ b/src/Bws.Gui/MainWindow.Keyboard.cs
@@ -158,7 +158,8 @@ internal bool Act(Shortcut shortcut, out Task work)
// selected, so nothing to complete - offers nothing. Said here rather than left to the focus event, because
// a box that already had the keyboard gets no focus event from this.
//
- // NOT ON THE OVERVIEW SCREEN, where the row holding the box is collapsed: a popup
+ // NOT ON THE OVERVIEW SCREEN NOR UNDER A COMPARISON (since 2026-10-14), where the row
+ // holding the box is collapsed: a popup
// hung off a collapsed target opens somewhere on the screen with nothing under it,
// and nothing would close it but Escape - the box never had the keyboard to lose.
// Asked of the model rather than of the box's visibility, because a window these
@@ -166,7 +167,7 @@ internal bool Act(Shortcut shortcut, out Task work)
Search.Box.Focus();
Search.Box.SelectAll();
- if (!_model.ShowingOverview)
+ if (_model.ListOnScreen)
{
_model.Suggesting.Arrived(Search.Box.Text);
}
@@ -227,7 +228,7 @@ internal bool Act(Shortcut shortcut, out Task work)
return _model.Suggesting.Close()
|| _model.Planned.Busy
|| _model.Planned.Hide()
- || _model.Chosen.Hide()
+ || _model.PutAwayAView()
|| _model.ClearQuery();
default:
diff --git a/src/Bws.Gui/MainWindow.Overview.cs b/src/Bws.Gui/MainWindow.Overview.cs
index 25ed44d..d5398e0 100644
--- a/src/Bws.Gui/MainWindow.Overview.cs
+++ b/src/Bws.Gui/MainWindow.Overview.cs
@@ -84,10 +84,17 @@ private void IntroduceTheOverview()
///
private void ArrangeTheMiddle()
{
- var overview = _model.ShowingOverview;
+ // FOUR CONTROLS SINCE 2026-10-14 - a comparison with a snapshot takes the middle the way the
+ // overview does (MainWindow.Snapshots.cs). It arrives only from the action bar, which the
+ // overview hides, and it puts the overview away when it comes - so the two never stand there
+ // together, and if they ever did the comparison is the newer thing somebody asked for.
+ var comparing = _model.Snapshotting.Shown is not null;
+ var overview = _model.ShowingOverview && !comparing;
+ var elsewhere = overview || comparing;
Overview.Visibility = overview ? Visibility.Visible : Visibility.Collapsed;
- Entries.Visibility = overview ? Visibility.Collapsed : Visibility.Visible;
+ Comparison.Visibility = comparing ? Visibility.Visible : Visibility.Collapsed;
+ Entries.Visibility = elsewhere ? Visibility.Collapsed : Visibility.Visible;
// THE EMPTY STATE IS PUT BACK BY CLEARING RATHER THAN BY SETTING, AND THE DIFFERENCE IS A
// FAULT THIS METHOD SHIPPED FOR ONE BUILD. That control decides its own visibility from a
@@ -98,7 +105,7 @@ private void ArrangeTheMiddle()
// Clearing hands the property back to the style, which is the only thing that knows whether
// there is anything to say. Setting Collapsed is still a local value and that is correct:
// while the overview has the middle of the window, nothing about a list is worth saying.
- if (overview)
+ if (elsewhere)
{
ListEmptyState.Visibility = Visibility.Collapsed;
@@ -142,7 +149,7 @@ private void ArrangeTheMiddle()
///
private void ArrangeTheChrome()
{
- var chrome = _model.ShowingOverview ? Visibility.Collapsed : Visibility.Visible;
+ var chrome = _model.ListOnScreen ? Visibility.Visible : Visibility.Collapsed;
Scope.Visibility = chrome;
Search.Visibility = chrome;
diff --git a/src/Bws.Gui/MainWindow.Suggesting.cs b/src/Bws.Gui/MainWindow.Suggesting.cs
index 2980089..4cc657a 100644
--- a/src/Bws.Gui/MainWindow.Suggesting.cs
+++ b/src/Bws.Gui/MainWindow.Suggesting.cs
@@ -64,7 +64,7 @@ internal void GiveTheBoxTheKeyboard() =>
System.Windows.Threading.DispatcherPriority.Input,
() =>
{
- if (_model.ShowingOverview)
+ if (!_model.ListOnScreen)
{
return;
}
diff --git a/src/Bws.Gui/MainWindow.xaml b/src/Bws.Gui/MainWindow.xaml
index 5c03352..91e0eb1 100644
--- a/src/Bws.Gui/MainWindow.xaml
+++ b/src/Bws.Gui/MainWindow.xaml
@@ -326,6 +326,13 @@
x:Name="Overview"
Visibility="Collapsed" />
+
+
+
640
- 900
+ 1000