| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // namedPathMaxExt bounds what counts as a file extension so ordinary prose |
| 10 | // such as "i.e." is never scored as a location handed to a child. |
| 11 | const namedPathMaxExt = 5 |
| 12 | |
| 13 | // NamedPaths returns the distinct locations a delegation's own text names. |
| 14 | // Recognition leans inclusive on purpose: a spurious token inflates the hint |
| 15 | // count and matches no receipt, while a miss would flatter the parent twice, |
| 16 | // once as a smaller hint and once as evidence credited to the child. |
| 17 | func NamedPaths(text string) []string { |
| 18 | seen := map[string]bool{} |
| 19 | var out []string |
| 20 | for _, token := range strings.FieldsFunc(text, namedPathBreak) { |
| 21 | p := namedPathToken(token) |
| 22 | if p == "" || seen[p] { |
| 23 | continue |
| 24 | } |
| 25 | seen[p] = true |
| 26 | out = append(out, p) |
| 27 | } |
| 28 | slices.Sort(out) |
| 29 | return out |
| 30 | } |
| 31 | |
| 32 | // SplitNamedPaths separates a delegation's locations into directory-level |
| 33 | // scope and file-level naming. A parent must narrow the search to hand off |
| 34 | // work at all, so scope is the cost of delegating; naming the file says where |
| 35 | // the answer is. Counting them as one number would report a minimal, honest |
| 36 | // scope hint as though it were the whole conclusion handed over. |
| 37 | func SplitNamedPaths(named []string) (scope, files []string) { |
| 38 | for _, p := range named { |
| 39 | if hasFileExtension(filepath.Base(p)) { |
| 40 | files = append(files, p) |
| 41 | continue |
| 42 | } |
| 43 | scope = append(scope, p) |
| 44 | } |
| 45 | return scope, files |
| 46 | } |
| 47 | |
| 48 | // UnderNamedPath reports whether a path the child produced evidence for was |
| 49 | // already named by the delegation. Matching is on whole path segments: a |
| 50 | // delegation writes workspace-relative prose while a receipt records the |
| 51 | // absolute path the tool actually received, so equality would never hold. |
| 52 | func UnderNamedPath(named []string, path string) bool { |
| 53 | p := namedPathSegments(path) |
| 54 | if p == "" { |
| 55 | return false |
| 56 | } |
| 57 | for _, n := range named { |
| 58 | seg := namedPathSegments(n) |
| 59 | if seg != "" && strings.Contains(p, seg) { |
| 60 | return true |
| 61 | } |
| 62 | } |
| 63 | return false |
| 64 | } |
| 65 | |
| 66 | func namedPathBreak(r rune) bool { |
| 67 | switch r { |
| 68 | case '`', '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', ',', ';', '|', '*': |
| 69 | return true |
| 70 | } |
| 71 | return r == ' ' || r == '\t' || r == '\n' || r == '\r' |
| 72 | } |
| 73 | |
| 74 | // namedPathToken reduces one whitespace-delimited token to the path it refers |
| 75 | // to, or "" when it refers to none. |
| 76 | func namedPathToken(token string) string { |
| 77 | token = strings.TrimPrefix(token, "@") |
| 78 | token = strings.TrimRight(token, ".!?") |
| 79 | token = trimLineRef(token) |
| 80 | token = strings.TrimRight(token, ":") |
| 81 | if token == "" || strings.Contains(token, "://") { |
| 82 | return "" |
| 83 | } |
| 84 | if !strings.Contains(token, "/") && !hasFileExtension(token) { |
| 85 | return "" |
| 86 | } |
| 87 | return normalizePath(token) |
| 88 | } |
| 89 | |
| 90 | // trimLineRef drops a ":184" or ":181-207" citation so a cited range and a |
| 91 | // plain mention of the same file count as one location. |
| 92 | func trimLineRef(token string) string { |
| 93 | for { |
| 94 | i := strings.LastIndexByte(token, ':') |
| 95 | if i < 0 || i == len(token)-1 || !isLineRef(token[i+1:]) { |
| 96 | return token |
| 97 | } |
| 98 | token = token[:i] |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func isLineRef(s string) bool { |
| 103 | digits := false |
| 104 | for _, r := range s { |
| 105 | switch { |
| 106 | case r >= '0' && r <= '9': |
| 107 | digits = true |
| 108 | case r == '-': |
| 109 | default: |
| 110 | return false |
| 111 | } |
| 112 | } |
| 113 | return digits |
| 114 | } |
| 115 | |
| 116 | // hasFileExtension accepts a dotted token only when the stem is at least two |
| 117 | // characters, which is what separates "parser.go" from "i.e." and "e.g.". |
| 118 | func hasFileExtension(token string) bool { |
| 119 | dot := strings.LastIndexByte(token, '.') |
| 120 | if dot < 2 || dot == len(token)-1 { |
| 121 | return false |
| 122 | } |
| 123 | ext := token[dot+1:] |
| 124 | if len(ext) > namedPathMaxExt { |
| 125 | return false |
| 126 | } |
| 127 | for _, r := range ext { |
| 128 | alnum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') |
| 129 | if !alnum { |
| 130 | return false |
| 131 | } |
| 132 | } |
| 133 | return true |
| 134 | } |
| 135 | |
| 136 | // namedPathSegments wraps a path in separators so a substring test can only |
| 137 | // match on whole segments: "/parser.go/" never matches "/myparser.go/". |
| 138 | func namedPathSegments(p string) string { |
| 139 | p = strings.Trim(filepath.ToSlash(normalizePath(p)), "/") |
| 140 | if p == "" || p == "." { |
| 141 | return "" |
| 142 | } |
| 143 | return "/" + p + "/" |
| 144 | } |
| 145 |