Skip to content

Commit 752cca8

Browse files
donislawdevclaude
andcommitted
guard: close the gaps two other projects pointed at
The owner shared the notes of two other projects of his, and the rule that held was: measurements, traps and methods transfer, conclusions do not. Two conclusions from there did not survive being checked here, and three of the traps found real defects. The conclusion that failed: a refusal comes out in the language of the machine. True there, on .NET, measured on two machines. Go does not behave that way - it asks Windows for the English message explicitly before falling back. Measured here on a Polish install, Errno(1) gives "Incorrect function." while FormatMessage on its own gives "Niepoprawna funkcja.". Writing their sentence down would have been a false claim about this tool. The change stands for a different reason. The system's sentence is opaque in any language - "Incorrect function." for reading a directory tells nobody anything - so describeError replaces it with ours and keeps the number, which is the part that means the same everywhere. Pointing a command at a directory now says so and prints what to run instead, which is the mistake somebody actually makes because the neighbouring commands take directories. Also checked and NOT adopted: their two command line findings, a flag silently accepted and a flag missing from the help. Both are impossible here by construction - a separate FlagSet per command refuses an unknown flag, and the help is generated from that same set. No guard was added for something the architecture already closes. The trap that paid off most: a guard kept by hand is a guard somebody eventually does not add. Two rows of the regression surface claimed a locality guard was proven by mutation and no entry named one, so untouchable rule 2 rested on a sentence. Four mutations went in, each aimed one test at a time - the first substitution reddened only the reordering guard, and naming any of the other three would have reported it as uncaught. Their question about a second Ctrl+C produced a probe and two answers. There is no second press to measure: the run ends within 0.00 s of the first, in both shapes tried, so every later press lands on a process that has already gone. And the probe crashed on "files": null in a manifest describing no files - a defect nobody was looking for, where every other empty collection beside it was written as {}. Six numbering schemes and 186 links between documents now have guards too. They skip loudly on a fresh checkout, because the documents live outside the repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3bcb429 commit 752cca8

7 files changed

Lines changed: 814 additions & 16 deletions

File tree

‎CHANGELOG-DEV.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,50 @@ belongs in one place.
3838
lowercase - the last one **the same length as what it replaced**, so the file
3939
does not move by a single byte and only a guard that knows SVG is case
4040
sensitive notices.
41+
- **The mutation list is kept by hand, so something now checks it is kept.**
42+
Every guard is named by a mutation, or written down as unproven, or written
43+
down as proven by probe - and which of the three is visible rather than
44+
assumed. The third state exists for guards that read documents, where there is
45+
no product code underneath for a mutation to reach.
46+
It earned its place immediately. Two rows of the regression surface said a
47+
locality guard was proven by mutation and no entry named one, so untouchable
48+
rule 2 - seeds are derived, never drawn from a shared stream - rested on a
49+
sentence rather than on a check. Four mutations went in for it, and each was
50+
aimed one test at a time: the first substitution reddened only the reordering
51+
guard, and naming any of the other three would have reported it as uncaught.
52+
- **A guard for the identifiers the documents are built on.** Six numbering
53+
schemes carry the decisions here, they collided once, and nothing was watching
54+
them. A reference now has to land on something, a range may not grow a hole -
55+
numbers are handed out once and never withdrawn - and the summary table in
56+
CLAUDE.md has to agree with what the six documents actually define. Measured
57+
when it went in: 66 identifiers across 13 documents, none dangling, no holes,
58+
every declared range correct.
59+
Proven by probe rather than by mutation, three ways: a reference to a decision
60+
that does not exist, a number removed from the middle of a range, and the
61+
summary table announcing a range the document no longer defines.
62+
- **A probe that sends a real Ctrl+C**, `tools/probes/ctrl-c-probe.py`. It is
63+
safe to run beside a working session because the child gets its own process
64+
group and the console event goes only there - sending to group 0 would hit the
65+
shell running it.
66+
What it settled: there is no second press to measure. The run ends within
67+
0.00 s of the first one in both shapes tried - thousands of small files and a
68+
single 400 MB file - so every later press lands on a process that has already
69+
gone. Six runs, exit 130 every time, manifest always written, files on disk
70+
always equal to manifest entries. The probe reports whether each press landed
71+
on a live run, because an earlier version sent four and measured one.
72+
It also crashed on `"files": null` and found a defect nobody was looking for.
73+
- **Errors from the system go through one place.** `describeError` swaps the
74+
system's sentence for ours wherever it sits in a wrapped chain and keeps every
75+
layer of context above it. Sixteen call sites route through it.
76+
**The finding that prompted this did not survive being checked, and the
77+
change stands for a different reason.** It came from another project of the
78+
owner's, where a refusal comes out in the language of the machine - measured
79+
there, on two machines. Go does not behave that way: it asks Windows for the
80+
English message explicitly before falling back to the machine's own. Measured
81+
here, on a Polish install, `syscall.Errno(1)` gives "Incorrect function."
82+
while FormatMessage on its own gives "Niepoprawna funkcja.". So the leak is
83+
only the fallback. The sentence is opaque in any language, which is the reason
84+
that held.
4185
- **One primitive for every record based format, in `core.FillRecords`.** Whole
4286
records while another still leaves room, then a closing record built to the
4387
byte. That rule was written out by hand for the third and fourth time when

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,24 @@ because it turns other people's test suites red.
233233

234234
### Fixed
235235

236+
- **Pointing a command at a directory says so.** `tfg verify out/` used to answer
237+
`read out: Incorrect function.`, which is what Windows says about reading a
238+
directory and which reads like a fault in this tool. All four commands that
239+
take a file - `verify`, `cleanup`, `validate` and `recipe fmt` - now say the
240+
path is a directory and print the command to run instead. The neighbouring
241+
commands take directories, so this is the mistake worth answering properly.
242+
- **A failure caused by the system is reported in our words, with the number.**
243+
The sentence an operating system hands back is opaque whatever language it is
244+
in, and on an install without the English message resource it is not English
245+
either. A missing recipe now reads `there is nothing at that path (system
246+
error 2)` rather than whatever the machine chose to say. The number is kept
247+
because it means the same thing everywhere.
248+
- **A manifest describing no files carries an empty list.** A run stopped before
249+
its first file finished wrote `"files": null`, while every other empty
250+
collection in the same manifest was written as `{}`. Anything reading the
251+
manifest and looping over the entries met a value that was not a list. It is
252+
`[]` now, at every size including nought.
253+
236254
- **A recipe saved by an editor that adds a byte order mark is read.** Notepad
237255
on Windows adds one by default. The mark used to reach the reader as part of
238256
the first key, so the tool reported `version` as an unknown field - and the

‎internal/cli/cli.go‎

Lines changed: 98 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"flag"
1414
"fmt"
1515
"io"
16+
"io/fs"
1617
"os"
1718
"path/filepath"
1819
"strings"
@@ -244,7 +245,7 @@ Flags:
244245

245246
bytesWanted, err := core.ParseSize(*sizeStr)
246247
if err != nil {
247-
fmt.Fprintf(errOut, "tfg: %v\n", err)
248+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
248249
return ExitUsage
249250
}
250251

@@ -272,7 +273,7 @@ Flags:
272273

273274
planned, err := engine.Plan(targets, opt)
274275
if err != nil {
275-
fmt.Fprintf(errOut, "tfg: %v\n", err)
276+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
276277
return classify(err)
277278
}
278279

@@ -294,7 +295,7 @@ Flags:
294295
}
295296

296297
if runErr != nil {
297-
fmt.Fprintf(errOut, "tfg: %v\n", runErr)
298+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(runErr))
298299
if !*dryRun {
299300
saveManifest(res, opt, errOut)
300301
}
@@ -311,7 +312,7 @@ Flags:
311312
if *asJSON {
312313
var buf bytes.Buffer
313314
if err := res.Manifest.Encode(&buf); err != nil {
314-
fmt.Fprintf(errOut, "tfg: cannot render the manifest: %v\n", err)
315+
fmt.Fprintf(errOut, "tfg: cannot render the manifest: %s\n", describeError(err))
315316
return ExitRuntime
316317
}
317318
out.Write(buf.Bytes())
@@ -403,17 +404,17 @@ func contentsOf(t recipe.Target) []format.Content {
403404
func loadRecipe(path string, errOut io.Writer) (*recipe.Recipe, string, int) {
404405
src, err := os.ReadFile(path)
405406
if err != nil {
406-
fmt.Fprintf(errOut, "tfg: cannot read the recipe %s: %v\n", path, err)
407+
fmt.Fprintf(errOut, "tfg: cannot read the recipe %s: %s\n", path, describeError(err))
407408
return nil, "", ExitIO
408409
}
409410
rec, err := recipe.Parse(src, path)
410411
if err != nil {
411-
fmt.Fprintf(errOut, "tfg: %v\n", err)
412+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
412413
return nil, "", classify(err)
413414
}
414415
hash, err := recipe.Hash(src)
415416
if err != nil {
416-
fmt.Fprintf(errOut, "tfg: %v\n", err)
417+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
417418
return nil, "", classify(err)
418419
}
419420
return rec, hash, ExitOK
@@ -438,6 +439,10 @@ func validate(args []string, out, errOut io.Writer) int {
438439
fmt.Fprintln(errOut, "tfg: validate takes one recipe file. Example: tfg validate recipe.yaml")
439440
return ExitUsage
440441
}
442+
if err := mustBeFile(path, "recipe.yaml", "validate"); err != nil {
443+
fmt.Fprintf(errOut, "tfg: %s\n", err)
444+
return ExitUsage
445+
}
441446

442447
rec, hash, code := loadRecipeReporting(path, *asJSON, errOut)
443448
if code != ExitOK {
@@ -465,7 +470,7 @@ func validate(args []string, out, errOut io.Writer) int {
465470
Problems: []validateProblem{{What: err.Error()}}})
466471
return classify(err)
467472
}
468-
fmt.Fprintf(errOut, "tfg: %v\n", err)
473+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
469474
return classify(err)
470475
}
471476

@@ -569,10 +574,14 @@ Flags:
569574
fmt.Fprintln(errOut, "tfg: verify takes one manifest file. Example: tfg verify out/manifest.json")
570575
return ExitUsage
571576
}
577+
if err := mustBeFile(path, "manifest.json", "verify"); err != nil {
578+
fmt.Fprintf(errOut, "tfg: %s\n", err)
579+
return ExitUsage
580+
}
572581

573582
m, err := manifest.Load(path)
574583
if err != nil {
575-
fmt.Fprintf(errOut, "tfg: %v\n", err)
584+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
576585
return classify(err)
577586
}
578587

@@ -673,10 +682,14 @@ Flags:
673682
fmt.Fprintln(errOut, "tfg: cleanup takes one manifest file. Example: tfg cleanup out/manifest.json")
674683
return ExitUsage
675684
}
685+
if err := mustBeFile(path, "manifest.json", "cleanup"); err != nil {
686+
fmt.Fprintf(errOut, "tfg: %s\n", err)
687+
return ExitUsage
688+
}
676689

677690
m, err := manifest.Load(path)
678691
if err != nil {
679-
fmt.Fprintf(errOut, "tfg: %v\n", err)
692+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
680693
return classify(err)
681694
}
682695

@@ -766,7 +779,7 @@ Flags:
766779
if blocked > 0 {
767780
fmt.Fprintf(errOut, "tfg: the manifest was kept because %d file(s) it lists are still there. It is the only record of them.\n", blocked)
768781
} else if err := os.Remove(path); err != nil {
769-
fmt.Fprintf(errOut, "tfg: cannot remove the manifest %s: %v\n", path, err)
782+
fmt.Fprintf(errOut, "tfg: cannot remove the manifest %s: %s\n", path, describeError(err))
770783
return ExitIO
771784
}
772785
}
@@ -898,16 +911,20 @@ Flags:
898911
fmt.Fprintln(errOut, "tfg: recipe fmt takes one recipe file. Example: tfg recipe fmt recipe.yaml")
899912
return ExitUsage
900913
}
914+
if err := mustBeFile(path, "recipe.yaml", "recipe fmt"); err != nil {
915+
fmt.Fprintf(errOut, "tfg: %s\n", err)
916+
return ExitUsage
917+
}
901918

902919
src, err := os.ReadFile(path)
903920
if err != nil {
904-
fmt.Fprintf(errOut, "tfg: cannot read the recipe %s: %v\n", path, err)
921+
fmt.Fprintf(errOut, "tfg: cannot read the recipe %s: %s\n", path, describeError(err))
905922
return ExitIO
906923
}
907924

908925
canon, err := recipe.Canonical(src, path)
909926
if err != nil {
910-
fmt.Fprintf(errOut, "tfg: %v\n", err)
927+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
911928
return classify(err)
912929
}
913930

@@ -929,7 +946,7 @@ Flags:
929946
return ExitOK
930947
}
931948
if err := os.WriteFile(path, canon, 0o644); err != nil {
932-
fmt.Fprintf(errOut, "tfg: cannot write %s: %v\n", path, err)
949+
fmt.Fprintf(errOut, "tfg: cannot write %s: %s\n", path, describeError(err))
933950
return ExitIO
934951
}
935952
fmt.Fprintf(errOut, "%s rewritten.\n", path)
@@ -950,7 +967,7 @@ func saveManifest(res *engine.Result, opt engine.Options, errOut io.Writer) int
950967
}
951968
path := filepath.Join(opt.OutDir, name)
952969
if err := res.Manifest.Save(path); err != nil {
953-
fmt.Fprintf(errOut, "tfg: cannot write the manifest to %s: %v\n", path, err)
970+
fmt.Fprintf(errOut, "tfg: cannot write the manifest to %s: %s\n", path, describeError(err))
954971
return ExitIO
955972
}
956973
fmt.Fprintf(errOut, "manifest: %s\n", path)
@@ -997,7 +1014,7 @@ func formats(args []string, out, errOut io.Writer) int {
9971014
enc := json.NewEncoder(out)
9981015
enc.SetIndent("", " ")
9991016
if err := enc.Encode(list); err != nil {
1000-
fmt.Fprintf(errOut, "tfg: cannot render the list: %v\n", err)
1017+
fmt.Fprintf(errOut, "tfg: cannot render the list: %s\n", describeError(err))
10011018
return ExitRuntime
10021019
}
10031020
return ExitOK
@@ -1016,6 +1033,71 @@ func formats(args []string, out, errOut io.Writer) int {
10161033
// The mapping lives here and nowhere else, and it works on error types rather
10171034
// than on message text. Anything unrecognised becomes a runtime error, which
10181035
// is the honest answer for a failure the tool did not anticipate.
1036+
// describeError renders an error for a person, in English, whatever language
1037+
// the operating system speaks.
1038+
//
1039+
// Every message this tool prints is English. A wrapped operating system error
1040+
// breaks that with nothing noticing, because the system formats its messages in
1041+
// the language of the machine - "Incorrect function." reaches somebody on a
1042+
// Polish install as a Polish sentence. The guard that scans this binary for non
1043+
// English text cannot see it, since that text is not in the binary at all. It
1044+
// arrives at run time.
1045+
//
1046+
// So the system's sentence is swapped for ours and every layer of our own
1047+
// context above it is kept. The number it carried stays, because a number means
1048+
// the same thing in every language and it is what somebody puts into a search.
1049+
func describeError(err error) string {
1050+
if err == nil {
1051+
return ""
1052+
}
1053+
1054+
var errno syscall.Errno
1055+
if !errors.As(err, &errno) {
1056+
return err.Error()
1057+
}
1058+
1059+
// Replace it wherever it sits rather than only at the end. A message built
1060+
// by wrapping prints the innermost error last, but nothing guarantees that,
1061+
// and a leak of one sentence is the whole defect.
1062+
full := err.Error()
1063+
if osText := errno.Error(); osText != "" && strings.Contains(full, osText) {
1064+
return strings.ReplaceAll(full, osText, systemReason(errno))
1065+
}
1066+
return full
1067+
}
1068+
1069+
// systemReason is our own English sentence for a system error, with the number
1070+
// beside it. The number is the part that survives translation.
1071+
func systemReason(errno syscall.Errno) string {
1072+
reason := "the system refused it"
1073+
switch {
1074+
case errors.Is(errno, fs.ErrNotExist):
1075+
reason = "there is nothing at that path"
1076+
case errors.Is(errno, fs.ErrPermission):
1077+
reason = "the system refused permission"
1078+
case errors.Is(errno, fs.ErrExist):
1079+
reason = "something is already there"
1080+
}
1081+
return fmt.Sprintf("%s (system error %d)", reason, uintptr(errno))
1082+
}
1083+
1084+
// mustBeFile turns the likeliest mistake into a sentence of ours.
1085+
//
1086+
// Every command below takes a file and the neighbouring ones take directories,
1087+
// so pointing one at a directory is the mistake somebody actually makes. Left
1088+
// alone it surfaces as whatever the system says about reading a directory,
1089+
// which on Windows is "Incorrect function." and says nothing to anybody.
1090+
func mustBeFile(path, kind, command string) error {
1091+
info, err := os.Stat(path)
1092+
if err != nil || !info.IsDir() {
1093+
// Anything else is left to the read that follows, so one fault gives
1094+
// one message.
1095+
return nil
1096+
}
1097+
return fmt.Errorf("%s is a directory and %s reads a file. Name the one inside it, for example: tfg %s %s",
1098+
path, command, command, filepath.Join(path, kind))
1099+
}
1100+
10191101
func classify(err error) int {
10201102
var recipeErr *engine.RecipeError
10211103
if errors.As(err, &recipeErr) {

0 commit comments

Comments
 (0)