Land the engine's schema gate: every statement is now compiled against the schema its own migration chain builds, instead of against whichever paths the tests happen to run
This commit is contained in:
parent
e585eb3a94
commit
6a6ce973a9
4 changed files with 624 additions and 5 deletions
575
backend/internal/store/sqlgate_test.go
Normal file
575
backend/internal/store/sqlgate_test.go
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sqlgate: every SQL statement this package can execute is COMPILED BY SQLITE against the schema the
|
||||
// migration chain builds, on every battery run.
|
||||
//
|
||||
// The class it closes is "the migration and the query drifted apart" — a column renamed in a migration
|
||||
// while the SQL that reads it was not, which SQLite reports only when something executes that path. A
|
||||
// behavioural test finds it only if it happens to run that statement, so the coverage of this class was
|
||||
// previously the coverage of the test suite, which is not the same thing and nobody was tracking the
|
||||
// difference. Here every statement is compiled whether or not any test reaches it.
|
||||
//
|
||||
// The schema is raised by running the product's OWN migration chain into a temporary file, so the gate
|
||||
// checks against exactly what a real project converges to rather than against a copy of the DDL that
|
||||
// could itself drift. That also makes the chain self-checking: a malformed migration fails this test
|
||||
// before any statement is looked at. Nothing external is required — SQLite's schema is a file, so the
|
||||
// gate holds on a clean machine with no stand, no environment and no manual setup.
|
||||
//
|
||||
// Compilation rather than execution: db.Prepare resolves every table, column and function name against
|
||||
// the schema and reports what is missing, while leaving `?` parameters unbound. Executing instead would
|
||||
// force the gate to invent values, and inventing them is how a gate starts asserting about types nobody
|
||||
// wrote down — and here it would also mean writing to the database it is inspecting.
|
||||
//
|
||||
// WHAT IT DOES NOT PROVE. That a statement is the RIGHT statement: a query naming only existing columns
|
||||
// passes here however wrong its logic, its joins or its ordering. That the values bound to `?` have the
|
||||
// right types — preparation leaves them unknown on purpose. That a statement's result is scanned into
|
||||
// the right Go fields, which is the ordinary business of the behavioural tests. And it says nothing
|
||||
// about paths a query takes at RUNTIME beyond the variants folded below: a statement this gate cannot
|
||||
// read is reported rather than skipped, so the boundary of what it sees is asserted, not assumed.
|
||||
func TestEverySQLStatementPreparesAgainstTheMigratedSchema(t *testing.T) {
|
||||
stmts := collectSQL(t)
|
||||
// A floor on the extractor itself. Without it, an extractor that quietly stopped finding call sites
|
||||
// would turn this gate into a green light over nothing. The number is the measured population minus
|
||||
// a small margin for ordinary churn: 70 statements resolve today, and a drop below 65 is a collapse
|
||||
// rather than an edit.
|
||||
if len(stmts) < 65 {
|
||||
t.Fatalf("the extractor resolved %d statements: it has stopped seeing this package, and a gate that checks nothing passes", len(stmts))
|
||||
}
|
||||
db := migratedSchema(t)
|
||||
for _, st := range stmts {
|
||||
if err := compiles(db, st.sql); err != nil {
|
||||
t.Errorf("%s: SQLite cannot compile this statement against the migrated schema: %v\n%s",
|
||||
st.where, err, indentSQL(st.sql))
|
||||
}
|
||||
}
|
||||
t.Logf("%d statements compiled against the schema at v%d", len(stmts), SchemaHead())
|
||||
}
|
||||
|
||||
// compiles hands one statement to SQLite for compilation and throws the result away. The prepared
|
||||
// statement is released here rather than at the end of the run: the population is the whole package,
|
||||
// and holding every one of them open would make the gate's own footprint grow with the thing it reads.
|
||||
func compiles(db *sql.DB, query string) error {
|
||||
stmt, err := db.Prepare(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// migratedSchema builds a project database with the product's own chain and hands back a connection to
|
||||
// it. Open is used rather than a hand-rolled sequence of CREATEs precisely so that the thing under
|
||||
// inspection is the schema the engine really produces.
|
||||
func migratedSchema(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "schema.db")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("building the schema to check against: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
// The migration chain is executed here as whole multi-statement scripts, which is why its own SQL is
|
||||
// not compiled statement by statement below: running it IS the check, and a chain that cannot build
|
||||
// its schema fails the line above. Asserting the version closes the gap between "Open returned" and
|
||||
// "the chain actually ran to head".
|
||||
var v int
|
||||
if err := s.r.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&v); err != nil {
|
||||
t.Fatalf("reading the schema version of the database this gate checks against: %v", err)
|
||||
}
|
||||
if v != SchemaHead() {
|
||||
t.Fatalf("the gate's database is at v%d, the binary's head is v%d: the statements below would be checked against a schema no run of this build produces", v, SchemaHead())
|
||||
}
|
||||
return s.r
|
||||
}
|
||||
|
||||
// statement is one SQL string this package can execute, and where it comes from.
|
||||
type statement struct {
|
||||
where string
|
||||
sql string
|
||||
}
|
||||
|
||||
// sqlCalls maps a call whose argument is SQL to the index of that argument. queryAll is the package's
|
||||
// own read helper; it is listed rather than excused, because ten statements reach the driver through it
|
||||
// and a gate that skipped them would skip most of the read models.
|
||||
//
|
||||
// Prepare and PrepareContext carry no SQL in this package today. They are listed anyway, because the
|
||||
// point of the map is to name EVERY database/sql entry point through which SQL can reach the driver: an
|
||||
// unlisted one is not a failure, it is silence, and the floor below cannot see a statement that was
|
||||
// never counted in the first place.
|
||||
var sqlCalls = map[string]int{
|
||||
"ExecContext": 1, "QueryContext": 1, "QueryRowContext": 1, "PrepareContext": 1,
|
||||
"Exec": 0, "Query": 0, "QueryRow": 0, "Prepare": 0,
|
||||
"queryAll": 1,
|
||||
}
|
||||
|
||||
// sqlHelpers are the functions in this package that take SQL as a PARAMETER. Their bodies execute
|
||||
// whatever a caller hands them, so the body is not a site of its own — the call sites are. Skipped
|
||||
// bodies are counted, and the count is asserted, so the skip cannot become the hole it exists to avoid.
|
||||
var sqlHelpers = map[string]bool{"queryAll": true}
|
||||
|
||||
// unresolvable is the call site whose SQL genuinely cannot be folded from source, with the shape it
|
||||
// takes at runtime. It is a TABLE and not a skip: an entry has to be written by hand, so a second such
|
||||
// site is somebody's deliberate decision rather than a hole that opened quietly.
|
||||
//
|
||||
// BackupSQLite builds `VACUUM INTO '<path>'` around a path chosen at runtime. The path is data, not
|
||||
// schema — the statement names no table and no column — so what the entry preserves is the only part a
|
||||
// schema gate can speak about: that the statement itself still compiles.
|
||||
//
|
||||
// ⚠ Keyed by the enclosing FUNCTION, not by a line number: a line number moves whenever somebody edits
|
||||
// the file above it, and a different statement would then inherit the entry and be checked against text
|
||||
// that is not its own. An entry covers exactly ONE site.
|
||||
//
|
||||
// ⚠ An entry is ANCHORED to its function's source: some string literal of that function must appear in
|
||||
// the entry. A hand-written string is the one thing here that cannot drift with the code it describes,
|
||||
// and without the anchor a statement could be rewritten while the gate went on compiling the old text
|
||||
// and reporting it green.
|
||||
var unresolvable = map[string]string{
|
||||
"BackupSQLite": `VACUUM INTO '/tmp/tm-sqlgate-representative.db'`,
|
||||
}
|
||||
|
||||
// migrationExecutors are the functions whose SQL is the migration chain itself. They are named here, and
|
||||
// their presence is asserted, because their argument is a whole multi-statement script: compiling it as
|
||||
// one statement is not possible, and it needs no compiling — migratedSchema runs the chain, so a chain
|
||||
// that does not build fails this test before a single query is read.
|
||||
var migrationExecutors = map[string]bool{"applyStep": true}
|
||||
|
||||
// collectSQL folds every SQL string in the package's non-test source.
|
||||
//
|
||||
// Folding rather than executing: the statements are literals, named constants, concatenations, one
|
||||
// table name ranging over a literal list and one query assembled by conditional appends. All of it is
|
||||
// decided at compile time, which is why a static reader can see all of it while a runtime one would see
|
||||
// only what the tests happened to run.
|
||||
func collectSQL(t *testing.T) []statement {
|
||||
t.Helper()
|
||||
sources, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usedException := map[string]int{}
|
||||
usedMigrationExecutor := map[string]int{}
|
||||
t.Cleanup(func() {
|
||||
// Every entry of the exception table has to be USED. An entry for a function that no longer has
|
||||
// unfoldable SQL is an exception standing over a statement nobody checks any more.
|
||||
for name := range unresolvable {
|
||||
if usedException[name] == 0 {
|
||||
t.Errorf("`unresolvable` still excuses %s, which no longer needs it: the exception now covers nothing and hides whatever moves under it", name)
|
||||
}
|
||||
}
|
||||
for name := range migrationExecutors {
|
||||
if usedMigrationExecutor[name] == 0 {
|
||||
t.Errorf("`migrationExecutors` still names %s, which no longer executes the chain: the schema it stands for is now checked by nothing", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
fset := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, path := range sources {
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
continue
|
||||
}
|
||||
f, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
// ⚠ TOP-LEVEL declarations only. Walking a whole file would descend into every function body, where
|
||||
// short names like `q` belong to a dozen different statements, and a query could then be checked
|
||||
// against another query's text and pass.
|
||||
pkgConst := map[string]ast.Expr{}
|
||||
for _, f := range files {
|
||||
for _, decl := range f.Decls {
|
||||
if g, ok := decl.(*ast.GenDecl); ok {
|
||||
collectConsts(g, pkgConst)
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []statement
|
||||
helperCalls := map[string]int{}
|
||||
helperBodies := 0
|
||||
for _, f := range files {
|
||||
for _, decl := range f.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
if sqlHelpers[fn.Name.Name] {
|
||||
helperBodies++
|
||||
continue
|
||||
}
|
||||
if migrationExecutors[fn.Name.Name] {
|
||||
usedMigrationExecutor[fn.Name.Name]++
|
||||
continue
|
||||
}
|
||||
// A name declared inside the function wins over a package-level one of the same name, so
|
||||
// the narrow scope is consulted first — short names like `q` are reused across this
|
||||
// package, and resolving one against another file's constant would check the wrong text.
|
||||
scope := map[string]ast.Expr{}
|
||||
collectConsts(fn, scope)
|
||||
env := &foldEnv{
|
||||
scopes: []map[string]ast.Expr{scope, pkgConst},
|
||||
ranged: rangeStrings(fn),
|
||||
appended: appendedStrings(t, fset, fn),
|
||||
}
|
||||
ast.Inspect(fn, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := calleeName(call)
|
||||
at, ok := sqlCalls[name]
|
||||
if !ok || at >= len(call.Args) {
|
||||
return true
|
||||
}
|
||||
if sqlHelpers[name] {
|
||||
helperCalls[name]++
|
||||
}
|
||||
where := shortPos(fset, call.Pos())
|
||||
variants, ok := env.foldAll(call.Args[at])
|
||||
if !ok {
|
||||
if known, listed := unresolvable[fn.Name.Name]; listed {
|
||||
if !anchoredInSource(fn, known) {
|
||||
t.Errorf("%s: the entry in `unresolvable` for %s no longer shares any literal with the function's source, so it describes a statement that is no longer there",
|
||||
where, fn.Name.Name)
|
||||
return true
|
||||
}
|
||||
usedException[fn.Name.Name]++
|
||||
if usedException[fn.Name.Name] > 1 {
|
||||
t.Errorf("%s: %s holds a second statement this gate cannot read, and the entry in `unresolvable` describes the first: checking it against that string would prove nothing about it",
|
||||
where, fn.Name.Name)
|
||||
return true
|
||||
}
|
||||
out = append(out, statement{where: where, sql: known})
|
||||
return true
|
||||
}
|
||||
// NOT skipped. A statement this gate cannot read is a statement nothing checks, and
|
||||
// the whole point is that there is no such thing by accident.
|
||||
t.Errorf("%s: the SQL handed to %s cannot be folded from source, so nothing checks it against the schema; make it a constant expression, or list it in `unresolvable` with the string it takes",
|
||||
where, name)
|
||||
return true
|
||||
}
|
||||
for i, sql := range variants {
|
||||
at := where
|
||||
if len(variants) > 1 {
|
||||
at = fmt.Sprintf("%s#%d", where, i+1)
|
||||
}
|
||||
out = append(out, statement{where: at, sql: sql})
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
for name := range sqlHelpers {
|
||||
if helperCalls[name] == 0 {
|
||||
t.Errorf("no call site of %s was found, and its body is skipped: the statements it executes are checked by nothing", name)
|
||||
}
|
||||
}
|
||||
if helperBodies == 0 {
|
||||
t.Error("no helper body was skipped: this gate no longer matches the package it reads")
|
||||
}
|
||||
slices.SortFunc(out, func(a, b statement) int { return strings.Compare(a.where, b.where) })
|
||||
return out
|
||||
}
|
||||
|
||||
// foldEnv is everything needed to turn one function's SQL expressions into the set of strings they can
|
||||
// take: named constants in scope, loop variables bound to literal lists, and locally assembled strings.
|
||||
type foldEnv struct {
|
||||
scopes []map[string]ast.Expr
|
||||
ranged map[string][]string
|
||||
appended map[string][]string
|
||||
}
|
||||
|
||||
// foldAll returns every string an expression can evaluate to. A set rather than a single value, because
|
||||
// a statement built around a loop variable or a conditional append is genuinely several statements, and
|
||||
// checking one of them would leave the others as unguarded as before this gate existed.
|
||||
func (e *foldEnv) foldAll(x ast.Expr) ([]string, bool) {
|
||||
switch v := x.(type) {
|
||||
case *ast.BasicLit:
|
||||
if v.Kind != token.STRING {
|
||||
return nil, false
|
||||
}
|
||||
s, err := strconv.Unquote(v.Value)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return []string{s}, true
|
||||
case *ast.ParenExpr:
|
||||
return e.foldAll(v.X)
|
||||
case *ast.BinaryExpr:
|
||||
if v.Op != token.ADD {
|
||||
return nil, false
|
||||
}
|
||||
left, okl := e.foldAll(v.X)
|
||||
right, okr := e.foldAll(v.Y)
|
||||
if !okl || !okr {
|
||||
return nil, false
|
||||
}
|
||||
var out []string
|
||||
for _, l := range left {
|
||||
for _, r := range right {
|
||||
out = append(out, l+r)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
case *ast.Ident:
|
||||
if vals, ok := e.ranged[v.Name]; ok {
|
||||
return vals, true
|
||||
}
|
||||
if vals, ok := e.appended[v.Name]; ok {
|
||||
return vals, true
|
||||
}
|
||||
for _, scope := range e.scopes {
|
||||
if def, ok := scope[v.Name]; ok {
|
||||
return e.foldAll(def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// rangeStrings binds a loop variable to the literal list it ranges over — `for _, table := range
|
||||
// []string{"a", "b"}`. Every element is a statement of its own: a table renamed in a migration breaks
|
||||
// exactly one of them, and a gate that folded only the first would report the other three as fine.
|
||||
func rangeStrings(fn *ast.FuncDecl) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
ast.Inspect(fn, func(n ast.Node) bool {
|
||||
rng, ok := n.(*ast.RangeStmt)
|
||||
if !ok || rng.Value == nil {
|
||||
return true
|
||||
}
|
||||
name, ok := rng.Value.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
lit, ok := rng.X.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
var vals []string
|
||||
for _, elt := range lit.Elts {
|
||||
bl, ok := elt.(*ast.BasicLit)
|
||||
if !ok || bl.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
s, err := strconv.Unquote(bl.Value)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
vals = append(vals, s)
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return true
|
||||
}
|
||||
if prior, seen := out[name.Name]; seen && !slices.Equal(prior, vals) {
|
||||
out[name.Name] = nil // two lists under one name: neither is safe to attribute to a call site
|
||||
return true
|
||||
}
|
||||
if _, blanked := out[name.Name]; !blanked || out[name.Name] != nil {
|
||||
out[name.Name] = vals
|
||||
}
|
||||
return true
|
||||
})
|
||||
for name, vals := range out {
|
||||
if vals == nil {
|
||||
delete(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// anchoredInSource reports whether the function still contains a string literal that the hand-written
|
||||
// exception is built around. Short literals are ignored: a lone quote or a format verb would anchor
|
||||
// anything to anything.
|
||||
func anchoredInSource(fn *ast.FuncDecl, entry string) bool {
|
||||
found := false
|
||||
ast.Inspect(fn, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
s, err := strconv.Unquote(lit.Value)
|
||||
if err != nil || len(s) < 8 {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(entry, s) {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// appendedStrings folds a query assembled by `q := "…"` followed by `q += "…"`, and returns every form
|
||||
// the finished string can take.
|
||||
//
|
||||
// An append guarded by an `if` is OPTIONAL, so the variable takes two values at that point and both are
|
||||
// checked: the filter clause of such a query names real columns, and it is the branch a caller reaches
|
||||
// only sometimes that a gate is most useful for. Anything else that writes to the variable — an append
|
||||
// inside a loop, whose repetition count is not a compile-time fact, or a value that is not foldable —
|
||||
// makes the variable UNREADABLE rather than half-read, and the site is then reported by the caller.
|
||||
func appendedStrings(t *testing.T, fset *token.FileSet, fn *ast.FuncDecl) map[string][]string {
|
||||
t.Helper()
|
||||
built := map[string][]string{}
|
||||
poisoned := map[string]bool{}
|
||||
defined := map[string]int{}
|
||||
env := &foldEnv{scopes: []map[string]ast.Expr{{}}}
|
||||
|
||||
var walk func(n ast.Node, conditional bool)
|
||||
walk = func(n ast.Node, conditional bool) {
|
||||
switch v := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
if len(v.Lhs) != 1 || len(v.Rhs) != 1 {
|
||||
return
|
||||
}
|
||||
name, ok := v.Lhs[0].(*ast.Ident)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
vals, folded := env.foldAll(v.Rhs[0])
|
||||
switch v.Tok {
|
||||
case token.DEFINE, token.ASSIGN:
|
||||
if v.Tok == token.DEFINE {
|
||||
// A name declared twice in one function is a shadow or a reuse, and either way the
|
||||
// fold can no longer say which declaration a given call site sees. Attributing the
|
||||
// wrong string to a statement is worse than admitting the statement is unreadable.
|
||||
defined[name.Name]++
|
||||
if defined[name.Name] > 1 {
|
||||
poisoned[name.Name] = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if !folded {
|
||||
// Only a string-shaped assignment can poison: a variable that never held SQL is not
|
||||
// this function's business.
|
||||
if _, tracked := built[name.Name]; tracked {
|
||||
poisoned[name.Name] = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if conditional {
|
||||
poisoned[name.Name] = true // a whole statement chosen in a branch is not an append
|
||||
return
|
||||
}
|
||||
built[name.Name] = vals
|
||||
case token.ADD_ASSIGN:
|
||||
base, tracked := built[name.Name]
|
||||
if !tracked {
|
||||
return
|
||||
}
|
||||
if !folded {
|
||||
poisoned[name.Name] = true
|
||||
return
|
||||
}
|
||||
var next []string
|
||||
if conditional {
|
||||
next = append(next, base...) // the branch not taken
|
||||
}
|
||||
for _, b := range base {
|
||||
for _, add := range vals {
|
||||
next = append(next, b+add)
|
||||
}
|
||||
}
|
||||
built[name.Name] = next
|
||||
}
|
||||
case *ast.IfStmt:
|
||||
for _, stmt := range v.Body.List {
|
||||
walk(stmt, true)
|
||||
}
|
||||
if v.Else != nil {
|
||||
walk(v.Else, true)
|
||||
}
|
||||
return
|
||||
case *ast.RangeStmt, *ast.ForStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt:
|
||||
// A body that may run any number of times cannot be folded into a finite set of strings.
|
||||
ast.Inspect(n, func(inner ast.Node) bool {
|
||||
as, ok := inner.(*ast.AssignStmt)
|
||||
if !ok || len(as.Lhs) != 1 {
|
||||
return true
|
||||
}
|
||||
if id, ok := as.Lhs[0].(*ast.Ident); ok {
|
||||
if _, tracked := built[id.Name]; tracked {
|
||||
poisoned[id.Name] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return
|
||||
case *ast.BlockStmt:
|
||||
for _, stmt := range v.List {
|
||||
walk(stmt, conditional)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, stmt := range fn.Body.List {
|
||||
walk(stmt, false)
|
||||
}
|
||||
for name := range poisoned {
|
||||
delete(built, name)
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
// collectConsts records every string const and var whose value is a constant expression.
|
||||
func collectConsts(n ast.Node, into map[string]ast.Expr) {
|
||||
ast.Inspect(n, func(n ast.Node) bool {
|
||||
decl, ok := n.(*ast.GenDecl)
|
||||
if !ok || (decl.Tok != token.CONST && decl.Tok != token.VAR) {
|
||||
return true
|
||||
}
|
||||
for _, spec := range decl.Specs {
|
||||
vs, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, name := range vs.Names {
|
||||
if i < len(vs.Values) {
|
||||
into[name.Name] = vs.Values[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// calleeName is the identifier a call names, whether it is a method, a plain function or a generic one
|
||||
// written with explicit type arguments.
|
||||
func calleeName(call *ast.CallExpr) string {
|
||||
switch fn := call.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
return fn.Sel.Name
|
||||
case *ast.Ident:
|
||||
return fn.Name
|
||||
case *ast.IndexExpr:
|
||||
if id, ok := fn.X.(*ast.Ident); ok {
|
||||
return id.Name
|
||||
}
|
||||
case *ast.IndexListExpr:
|
||||
if id, ok := fn.X.(*ast.Ident); ok {
|
||||
return id.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortPos(fset *token.FileSet, p token.Pos) string {
|
||||
pos := fset.Position(p)
|
||||
return fmt.Sprintf("%s:%d", filepath.Base(pos.Filename), pos.Line)
|
||||
}
|
||||
|
||||
func indentSQL(sql string) string {
|
||||
return "\t" + strings.ReplaceAll(strings.TrimSpace(sql), "\n", "\n\t")
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
|||
# Реестр D-нот — карта актуальности v2 (D1–D39.172;
|
||||
# Реестр D-нот — карта актуальности v2 (D1–D39.173;
|
||||
|
||||
> ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА
|
||||
> у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк
|
||||
|
|
@ -233,3 +233,4 @@
|
|||
| D39.170 | 29.08 | **Движковый пак «деньги» принят и заленджен**: потолок ОБЪЁМА оплаченной работы (`--max-units`) + читающий путь `status` теперь СВОРАЧИВАЕТ банк, поэтому смета пере-прохода впервые доезжает до покупателя ДО покупки, оставаясь $0. ⚠ Пак СНЯЛ ПОСЫЛКУ чужой зоны: платформа не берёт `rebill_*` по доводу «status читает ноль сразу после apply» (`ingest/resync.go:37-43`) — довод устарел. Четыре круга приёмки; блокирующая находка охотника: потолок ПРОБИВАЛСЯ пере-сидом банка посреди прогона (грант 1 → 4 вызова, $0.0073 вместо $0.0036, две доставленные главы оплачены дважды и названы бесплатными). Лечение структурное — пере-план + отказ волны работать с планом чужого снапшота. Сессия не воспроизвела сценарий ПЯТЬ раз при верных прогонах: дельту двигает не текст, а предложения черновиков, а `spoilerBlocked` (`membank/memory.go:640-647`) режет термин с поздним `since_ch` навсегда. | жив | ЖИВОЕ: ось отгрузки на `once_key` — отдельный заказ; терминолог вне потолка — вход в калибровку цены; проводка `--max-units` ГЕЙЧЕНА | движок деньги потолок приёмка шов |
|
||||
| D39.171 | 29.08 | **Гейт денежной безопасности при `kill -9` четверть раундов проверял ТАВТОЛОГИЮ.** `kill9_test.go` сторожил живость счётчиком по ВСЕЙ базе, а путь создаётся вне цикла раундов ⇒ страж пуст в КАЖДОМ раунде; на пустой базе `committed == sum` выполняется как `0 == 0`. Замер: 10 раундов из 40 не утверждали ничего. Доказано сравнением двух форм под ОДНОЙ посадкой: старый тест PASS при четырёх пустых раундах, новый FAIL на первом. Второй тест ужимал бюджет ДО построения фикстуры (17 чужих операций под усечением). Оба починены структурно (рукопожатие + сверка прироста; усечение только на предмет), боевой код не тронут. **Норма: тест не имеет права утверждать о ВРЕМЕНИ, если предмет утверждения — не время; тихо-зелёное дороже красного.** | жив | ЖИВОЕ: две замеренные формы дефектного теста; узость класса проверяется по формам и зоне, а не вообще | тесты гейты движок приёмка |
|
||||
| D39.172 | 29.08 | **Пак `sqlc` принят и заленджен**: 40 запросов пяти файлов `pgstore` на типизированный слой, пин 1.31.1, `sqlc diff` в `make check` + гейт без установленного инструмента. ⚠ Решение принято НЕ тем доводом, которым заказывалось: счёт покрытия дал «почти ничего» (3 из 42), а шесть посаженных мутаций ВЫЖИЛИ при зелёной батарее — `sqlgate` видит строку SQL и никогда Go-сторону вызова. Подтверждено независимо на денежном пути: перестановка целей `Scan` в `ReadAccount` зелена на всей батарее и переворачивает числа оператора при дрейфе (`PD-430`). Честные границы: `observe.go` структурно не конвертируется (River мигрирует `river_job` сам), худший позиционный дрейф остался рукописным; `Touch` выбрасывает `RowsAffected` — не чинится конверсией. | жив | ЖИВОЕ: П-19 править на 40 конвертируемых; `Touch` строкой; пересборка `tmctl` в рецепте стенда; `PD-423` пере-проверить | платформа sqlc гейты деньги |
|
||||
| D39.173 | 30.08 | **У движка появился гейт схемы**: каждый SQL-оператор компилируется `db.Prepare` против схемы, поднятой ПРОДОВОЙ цепью миграций во временный файл — раньше покрытие этого класса равнялось покрытию батареи. ОДИН новый тестовый файл, продовый код не тронут. Склейки не пропускаются, а раскладываются (перебор четырёх таблиц, условная сборка из двух ветвей). 70 операторов против схемы v16 за 0.02 с, пол экстрактора 65. Расхождений в живом коде нет. Внешнего не требуется — у SQLite схема это файл. | жив | ЖИВОЕ: гейт не доказывает верность оператора, только существование имён; пол 65 — суждение | движок гейты схема тесты |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.172 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
# Журнал решений оркестратора — контракт D1–D39.173 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+).
|
||||
|
|
@ -1470,3 +1470,47 @@ head-1 это семнадцать бюджетируемых операций,
|
|||
Рецепт в `STACK_DECISIONS` про пересборку не говорит; строка заведена. Отдельно: **`PD-423` у сессии НЕ
|
||||
воспроизвёлся** — `TestARunIsBoundedByItsOwnCgroup` зелёный в трёх прогонах при cgroup `/init.scope`,
|
||||
то есть условие перемежающееся, и строку надо пере-проверить, а не закрывать.
|
||||
|
||||
## D39.173 — У ДВИЖКА ПОЯВИЛСЯ ГЕЙТ СХЕМЫ: каждый SQL-оператор компилируется против мигрированной схемы, а не против удачи покрытия (30.08, оркестратор №19). ✅
|
||||
|
||||
**Что заленджено.** `backend/internal/store/sqlgate_test.go` — ОДИН новый файл, только тест. **Продовый
|
||||
код не тронут ни строкой, ни один существующий тест не изменён.** Гейт закрывает класс «миграция и
|
||||
запрос разошлись»: раньше его покрытие равнялось покрытию батареи, и разницу никто не отслеживал.
|
||||
|
||||
**Что построено и чем это отличается от платформенного оригинала.** Проверка — `db.Prepare`, а не
|
||||
`EXPLAIN` и не исполнение: подготовка резолвит каждое имя таблицы, колонки и функции, оставляя
|
||||
параметры несвязанными; исполнение заставило бы гейт выдумывать значения и писать в базу, которую он
|
||||
осматривает. Схема поднимается прогоном ПРОДОВОЙ цепи миграций во временный файл, поэтому цепь заодно
|
||||
становится самопроверяемой — сломанная миграция валит тест раньше, чем прочитан первый запрос, — и
|
||||
сверяется с `SchemaHead()`: «Open вернулся» не то же самое, что «цепь дошла до головы». **Внешнего не
|
||||
требуется ничего:** у SQLite схема это файл, гейт держится на чистой машине без стенда и переменных —
|
||||
в отличие от платформенного, которому нужен живой Postgres.
|
||||
|
||||
⚠ **Склейки не пропускаются, а РАСКЛАДЫВАЮТСЯ, и это сильнее оригинала.** Обе склейки движка дают
|
||||
больше одного оператора: перебор четырёх имён таблиц (`glossary.go:145`) и условная сборка из двух
|
||||
веток (`ledger.go:334`), где ветка с фильтром достижима лишь иногда — ровно то, ради чего гейт и нужен.
|
||||
Свернувший только первый вариант объявил бы остальные проверенными. Что не раскладывается — ОШИБКА, а
|
||||
не пропуск.
|
||||
|
||||
**Приёмка — исполнением, мои посадки поверх её.** Батарея целиком: `EXIT=0`, 17 пакетов, линтер
|
||||
`0 issues`; сам гейт — **70 операторов против схемы v16 за 0.02 с**. Моя посадка «колонка переименована
|
||||
в ЗАПРОСЕ» поймана адресно, с файлом, строкой, колонкой и текстом оператора. Моя посадка «экстрактор
|
||||
ослеплён на один вид вызова» поймана полом: `resolved 38 statements`, порог 65.
|
||||
|
||||
**Расхождений в живом коде гейт НЕ нашёл** — все 70 операторов компилируются. То есть дыра закрыта до
|
||||
того, как выстрелила, а не после: в платформе тот же класс дважды давал падения в рантайме.
|
||||
|
||||
**Честные границы, названные сессией против себя.** (а) Гейт не доказывает, что оператор ВЕРНЫЙ —
|
||||
только что каждое имя существует и SQLite его компилирует; логика, джойны, типы под параметрами и скан
|
||||
в поля Go вне его, и это написано в доккомментарии. (б) Пол 65 — суждение, а не факт: законный
|
||||
рефактор, убравший шесть операторов, даст ложное красное. (в) **Вклад гейта во время батареи
|
||||
неизмерим** — его собственная стоимость 0.02 с, а разброс между прогонами около 30 с, на три порядка
|
||||
больше; сессия отказалась записывать себе ускорение, которого не было.
|
||||
|
||||
⚠ **Два урока процесса, оба названы сессией сами.** Первая посадка ушла мимо цели: переименование
|
||||
таблицы в её `CREATE` уронило сборку схемы, а не запрос — у гейта два режима отказа, и в логе их легко
|
||||
перепутать. И второй раз за двое суток сессия начала с `go test` вместо `make battery`, и линтер поймал
|
||||
её на собственной строке; починила по существу, а не подавлением. Тот же урок, на котором я попался
|
||||
позавчера.
|
||||
|
||||
**Строка 235 единого бэклога ЗАКРЫТА.**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue