repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

27.8 KB · 890 lines · Go Raw History
  1package main
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"go/parser"
  7	"go/token"
  8	"regexp"
  9	"strings"
 10)
 11
 12// The code shape is the one where the reader does not read the answer, they
 13// paste it, so the things worth checking are different. A sentence can be a
 14// little vague and still be useful. A file that does not parse is worth
 15// nothing, and neither is one with a hole in the middle where the model ran
 16// out of passages.
 17//
 18// Everything here is static and runs in process, because the image is FROM
 19// scratch and there is no interpreter in it to run anything against.
 20
 21// CodeBlock is one fenced block lifted out of an answer.
 22type CodeBlock struct {
 23	Lang string
 24	// File is the bold file name on the line above the fence, when the model
 25	// wrote one, since the contract asks for **app.py** above each block.
 26	File string
 27	Code string
 28	Line int
 29	// Closed is false when the fence was never closed, which is what a block
 30	// truncated by the token budget looks like.
 31	Closed bool
 32}
 33
 34// CodeCheck is one verdict, one per block, shown to the reader.
 35type CodeCheck struct {
 36	File  string
 37	Lang  string
 38	Lines int
 39	OK    bool
 40	Note  string
 41	// Truncated separates running out of tokens from writing something wrong.
 42	// Searching again cannot fix a length problem, so it is not worth the
 43	// three searches a second round costs.
 44	Truncated bool
 45}
 46
 47var (
 48	fenceOpen = regexp.MustCompile("^\\s{0,3}```+\\s*([A-Za-z0-9_+#.-]*)\\s*$")
 49	fenceShut = regexp.MustCompile("^\\s{0,3}```+\\s*$")
 50	boldFile  = regexp.MustCompile(`^\s*\*\*([^*]+?)\*\*\s*:?\s*$`)
 51	// A file name is a name with an extension, or one of the few that carry
 52	// none. Without this the heading above a block ("**Run it**") is read as a
 53	// file name and the check panel lists a file nobody wrote.
 54	looksLikeFile = regexp.MustCompile(`^[\w./-]+\.[A-Za-z0-9]{1,10}$|^(Dockerfile|Makefile|Procfile|\.env|\.gitignore|docker-compose\.ya?ml)$`)
 55)
 56
 57// codeBlocks pulls the fenced blocks out of an answer, keeping the file name
 58// written above each one.
 59func codeBlocks(md string) []CodeBlock {
 60	lines := strings.Split(md, "\n")
 61	var out []CodeBlock
 62	for i := 0; i < len(lines); i++ {
 63		m := fenceOpen.FindStringSubmatch(lines[i])
 64		if m == nil {
 65			continue
 66		}
 67		b := CodeBlock{Lang: strings.ToLower(m[1]), Line: i + 1, File: fileAbove(lines, i)}
 68		var body []string
 69		j := i + 1
 70		for ; j < len(lines); j++ {
 71			if fenceShut.MatchString(lines[j]) {
 72				b.Closed = true
 73				break
 74			}
 75			body = append(body, lines[j])
 76		}
 77		// The name comes off a leading comment when it was not written above
 78		// the fence, and the comment goes with it, so what is checked here is
 79		// what liftFileComments leaves for the reader to copy.
 80		if b.File == "" && len(body) > 0 {
 81			if name := fileInComment(body[0]); name != "" {
 82				b.File, body = name, body[1:]
 83			}
 84		}
 85		b.Code = strings.Join(body, "\n")
 86		out = append(out, b)
 87		i = j
 88	}
 89	return out
 90}
 91
 92// fileAbove reads back over blank lines for the bold file name the contract
 93// asks for above each block.
 94func fileAbove(lines []string, fence int) string {
 95	for i := fence - 1; i >= 0 && i >= fence-3; i-- {
 96		t := strings.TrimSpace(lines[i])
 97		if t == "" {
 98			continue
 99		}
100		m := boldFile.FindStringSubmatch(t)
101		if m == nil {
102			return ""
103		}
104		// "**File: server.py**" is as common as the bare name and means the
105		// same thing.
106		name := strings.TrimSpace(m[1])
107		if i := strings.IndexByte(name, ':'); i >= 0 {
108			name = strings.TrimSpace(name[i+1:])
109		}
110		if looksLikeFile.MatchString(name) {
111			return name
112		}
113		return ""
114	}
115	return ""
116}
117
118// fileInComment reads a file name off a comment on the first line, which is
119// where the model puts it about half the time whatever the contract says.
120func fileInComment(line string) string {
121	first := strings.TrimSpace(strings.SplitN(line, "\n", 2)[0])
122	for _, marker := range []string{"#", "//", "--", "<!--"} {
123		if !strings.HasPrefix(first, marker) {
124			continue
125		}
126		name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(first, marker)), "-->"))
127		if looksLikeFile.MatchString(name) {
128			return name
129		}
130	}
131	return ""
132}
133
134// proseOnly is the answer with the code taken out.
135//
136// Validation checks a sentence against the passage it cites, and a line of
137// Python is not a sentence: splitting it produces claims nothing can entail,
138// each one a model call, and `data[0]` reads as a citation of passage 0. So
139// the checker sees the prose and the code is checked by parsing it instead.
140func proseOnly(md string) string {
141	lines := strings.Split(md, "\n")
142	var out []string
143	skip := false
144	for _, l := range lines {
145		switch {
146		case skip:
147			if fenceShut.MatchString(l) || fenceOpen.MatchString(l) {
148				skip = false
149			}
150		case fenceOpen.MatchString(l):
151			skip = true
152		default:
153			out = append(out, l)
154		}
155	}
156	return strings.Join(out, "\n")
157}
158
159// eachProseLine runs fn over the lines outside code blocks and keeps the rest
160// byte for byte. Every tidy-up here is written for prose, and a line of Python
161// that happens to match one of those patterns is not prose.
162func eachProseLine(md string, fn func(string) (string, bool)) string {
163	lines := strings.Split(md, "\n")
164	out := make([]string, 0, len(lines))
165	in := false
166	for _, l := range lines {
167		switch {
168		case fenceOpen.MatchString(l) && !in:
169			in = true
170		case in && fenceShut.MatchString(l):
171			in = false
172		case !in:
173			kept, ok := fn(l)
174			if !ok {
175				continue
176			}
177			l = kept
178		}
179		out = append(out, l)
180	}
181	return strings.Join(out, "\n")
182}
183
184// dropMeta removes a sentence that talks about the answer rather than
185// answering. A 4B writes "This response provides a Dockerfile, citing the
186// relevant passages" however plainly the contract forbids it, and it is the
187// first line a reader sees.
188//
189// A sentence ends at a full stop with a space after it. Splitting on the stop
190// alone cut "the `leaflet.webgl-temperature-map` library" in half and left the
191// answer opening mid-word, which is worse than the sentence it removed.
192var metaSentence = regexp.MustCompile(`(?i)(^|[.!?]\s+)(?:[^.!?]|[.!?]\S)*\b(?:this (?:response|answer|reply)|the following (?:response|answer)|as an ai|below (?:you will find|is the answer))\b(?:[^.!?]|[.!?]\S)*[.!?](\s+|$)`)
193
194func dropMeta(text string) string {
195	return eachProseLine(text, func(l string) (string, bool) {
196		if !metaSentence.MatchString(l) {
197			return l, true
198		}
199		cleaned := strings.TrimSpace(metaSentence.ReplaceAllString(l, "$1 "))
200		// A line that was only meta goes entirely, and a list marker left on
201		// its own goes with it.
202		if cleaned == "" || cleaned == "-" || cleaned == "*" {
203			return "", false
204		}
205		return cleaned, true
206	})
207}
208
209// stripCodeCitations removes citation markers from inside code blocks.
210//
211// The prompt says not to put them there and a 4B does it anyway, and unlike a
212// stray marker in prose this one is pasted into a file and stops it running.
213// Prose keeps its markers, which is the whole point of them.
214func stripCodeCitations(md string) string {
215	lines := strings.Split(md, "\n")
216	in := false
217	for i, l := range lines {
218		switch {
219		case fenceOpen.MatchString(l) && !in:
220			in = true
221		case in && fenceShut.MatchString(l):
222			in = false
223		case in && citation.MatchString(l):
224			// Only where it reads as a marker rather than as an index, so
225			// `items[0]` and `x = [1]` survive and `print(x) [3]` does not.
226			cleaned := trailingCitation.ReplaceAllString(l, "")
227			if c := commentAt(cleaned); c >= 0 {
228				cleaned = cleaned[:c] + citation.ReplaceAllString(cleaned[c:], "")
229			}
230			lines[i] = strings.TrimRight(cleaned, " \t")
231		}
232	}
233	return strings.Join(lines, "\n")
234}
235
236// commentAt is where a line comment starts, or -1. Rough on purpose: it is
237// used to decide whether a citation on this line is prose, and a "#" inside a
238// string is not worth a lexer.
239func commentAt(line string) int {
240	best := -1
241	for _, marker := range []string{"#", "//", "--", "/*", "<!--"} {
242		if i := strings.Index(line, marker); i >= 0 && (best < 0 || i < best) {
243			best = i
244		}
245	}
246	return best
247}
248
249// A marker after code and before the end of the line, or after a comment
250// character. An index is always attached to the name in front of it.
251var trailingCitation = regexp.MustCompile(`(\s+\[\d{1,3}\])+\s*$`)
252
253// liftFileComments removes the "# app.py" line from the top of a block whose
254// name is already shown above it. The name belongs in one place, and inside
255// the block it is a line the reader pastes into their own file.
256func liftFileComments(md string) string {
257	lines := strings.Split(md, "\n")
258	out := make([]string, 0, len(lines))
259	in, first := false, false
260	for _, l := range lines {
261		switch {
262		case fenceOpen.MatchString(l) && !in:
263			in, first = true, true
264		case in && fenceShut.MatchString(l):
265			in = false
266		case in && first:
267			first = false
268			if fileInComment(l) != "" {
269				continue
270			}
271		}
272		out = append(out, l)
273	}
274	return strings.Join(out, "\n")
275}
276
277// checkCode parses what can be parsed and looks for the holes a model leaves
278// when it is writing from fragments.
279func checkCode(blocks []CodeBlock) []CodeCheck {
280	var out []CodeCheck
281	for _, b := range blocks {
282		if strings.TrimSpace(b.Code) == "" {
283			continue
284		}
285		c := CodeCheck{File: b.File, Lang: b.Lang, Lines: len(strings.Split(b.Code, "\n")), OK: true}
286		if c.Lang == "" {
287			c.Lang = "text"
288		}
289		switch {
290		case !b.Closed:
291			c.OK, c.Truncated = false, true
292			c.Note = "ran out of room before the block ended, so it stops partway"
293		default:
294			if note := inspect(b); note != "" {
295				c.OK, c.Note = false, note
296			}
297		}
298		if c.OK {
299			c.Note = passNote(b)
300		}
301		out = append(out, c)
302	}
303	return out
304}
305
306// inspect returns what is wrong with a block, or an empty string.
307func inspect(b CodeBlock) string {
308	if note := metaInCode(b.Code); note != "" {
309		return note
310	}
311	if note := placeholderIn(b.Code); note != "" {
312		return note
313	}
314	switch normalLang(b.Lang, b.File) {
315	case "go":
316		return goSyntax(b.Code)
317	case "json":
318		if !json.Valid([]byte(b.Code)) {
319			return "not valid JSON"
320		}
321	case "python":
322		if note := balance(b.Code, pythonScan); note != "" {
323			return note
324		}
325		if note := pythonIndent(b.Code); note != "" {
326			return note
327		}
328		return undefinedInFString(b.Code)
329	case "javascript":
330		return balance(b.Code, cLikeScan)
331	case "html":
332		if note := htmlBalance(b.Code); note != "" {
333			return note
334		}
335		return missingLibrary(b.Code)
336	case "yaml":
337		return yamlTabs(b.Code)
338	case "dockerfile":
339		return dockerfileSyntax(b.Code)
340	}
341	return ""
342}
343
344// passNote says what was actually done, since "checked" with no detail invites
345// the reader to think more was checked than was.
346func passNote(b CodeBlock) string {
347	switch normalLang(b.Lang, b.File) {
348	case "go":
349		return "parses"
350	case "json":
351		return "valid JSON"
352	case "python", "javascript":
353		return "brackets balance"
354	case "html":
355		return "tags balance"
356	case "yaml":
357		return "indented with spaces"
358	case "dockerfile":
359		return "instructions are real ones"
360	}
361	return "read, not parsed"
362}
363
364// normalLang folds the tags people write onto the ones checked here, and falls
365// back to the file name when the fence carries no language.
366func normalLang(lang, file string) string {
367	switch lang {
368	case "go", "golang":
369		return "go"
370	case "py", "python", "python3":
371		return "python"
372	case "js", "javascript", "jsx", "mjs", "node", "ts", "typescript", "tsx":
373		return "javascript"
374	case "json":
375		return "json"
376	case "html", "htm":
377		return "html"
378	case "yaml", "yml":
379		return "yaml"
380	case "dockerfile", "docker":
381		return "dockerfile"
382	}
383	switch {
384	case file == "":
385		return lang
386	case strings.HasPrefix(file, "Dockerfile"), file == "Dockerfile":
387		return "dockerfile"
388	case strings.HasSuffix(file, ".go"):
389		return "go"
390	case strings.HasSuffix(file, ".py"):
391		return "python"
392	case strings.HasSuffix(file, ".js"), strings.HasSuffix(file, ".ts"):
393		return "javascript"
394	case strings.HasSuffix(file, ".json"):
395		return "json"
396	case strings.HasSuffix(file, ".html"):
397		return "html"
398	case strings.HasSuffix(file, ".yml"), strings.HasSuffix(file, ".yaml"):
399		return "yaml"
400	}
401	return lang
402}
403
404// goSyntax runs the real parser, which is the one language here that can be
405// checked properly without leaving the standard library. A snippet is not a
406// file, so a bare fragment gets wrapped before it is rejected.
407func goSyntax(src string) string {
408	tries := []string{src}
409	if !strings.Contains(src, "package ") {
410		tries = append(tries, "package p\n"+src)
411		if !strings.Contains(src, "func ") {
412			tries = append(tries, "package p\nfunc p() {\n"+src+"\n}")
413		}
414	}
415	var first error
416	for _, try := range tries {
417		_, err := parser.ParseFile(token.NewFileSet(), "x.go", try, parser.AllErrors)
418		if err == nil {
419			return ""
420		}
421		if first == nil {
422			first = err
423		}
424	}
425	msg := first.Error()
426	if i := strings.Index(msg, "\n"); i > 0 {
427		msg = msg[:i]
428	}
429	return "does not parse: " + strings.TrimPrefix(msg, "x.go:")
430}
431
432// A model that cannot find how a library works argues with itself in a comment
433// and then writes a stand-in value, which is the worst thing a code answer can
434// do: it is complete, it runs, and it does nothing. Worth failing the block
435// over, since the reader would have to read every comment to notice.
436var metaComment = regexp.MustCompile(`(?i)(provided (?:text|passage|context|basic usage)|the passages?\b|in the (?:provided|given) \w+|based on the (?:provided|given)|not explicitly (?:defined|mentioned|in)|for the purpose of (?:the|this) demo|placeholder for)`)
437
438func metaInCode(code string) string {
439	if metaComment.MatchString(code) {
440		return "argues about what the sources said in its own comments instead of doing the work"
441	}
442	return ""
443}
444
445var placeholders = []struct {
446	re   *regexp.Regexp
447	note string
448}{
449	{regexp.MustCompile(`(?im)^\s*(?:#|//|--|/\*|<!--)?\s*\.\.\.\s*(?:\*/|-->)?\s*$`), "has an ellipsis standing in for code that was not written"},
450	{regexp.MustCompile(`(?i)(rest of (?:the |your )?(?:code|file|implementation)|your code here|code goes here|implement(?:ation)? (?:this|here|goes here)|same as (?:above|before)|remaining \w+ (?:here|omitted)|\bomitted for brevity\b|\betc\.\.\.)`), "says the rest of the code goes here rather than writing it"},
451	{regexp.MustCompile(`(?i)TODO:?\s*(implement|fill|add your|complete)`), "leaves a TODO where working code should be"},
452	// A file that describes itself as an example of what a real one would do
453	// is not one, and it is what a model writes when the passages never showed
454	// it the real thing.
455	{regexp.MustCompile(`(?i)(conceptual (?:example|implementation)|in a real (?:environment|system|setup|deployment)|this is pseudo|pseudo-?code|for illustration only|does not actually|would actually (?:run|execute|invoke)|represents the logic)`), "describes what a working version would do rather than being one"},
456}
457
458func placeholderIn(code string) string {
459	for _, p := range placeholders {
460		if p.re.MatchString(code) {
461			return p.note
462		}
463	}
464	return ""
465}
466
467// scan describes how to skip the parts of a language where a bracket is not a
468// bracket. Counting them naively calls every string holding a "(" unbalanced,
469// which is a false alarm on almost every real file.
470type scan struct {
471	lineComment []string
472	blockOpen   string
473	blockClose  string
474	quotes      []string
475	// triples are Python's, and they have to be tried before the single
476	// character quotes or the first two characters close an empty string.
477	triples []string
478	escape  bool
479}
480
481var (
482	cLikeScan  = scan{lineComment: []string{"//"}, blockOpen: "/*", blockClose: "*/", quotes: []string{`"`, "'", "`"}, escape: true}
483	pythonScan = scan{lineComment: []string{"#"}, triples: []string{`"""`, "'''"}, quotes: []string{`"`, "'"}, escape: true}
484)
485
486// balance walks the code outside strings and comments and reports the first
487// bracket that does not close.
488func balance(code string, s scan) string {
489	var stack []byte
490	pair := map[byte]byte{')': '(', ']': '[', '}': '{'}
491	line := 1
492
493	for i := 0; i < len(code); {
494		c := code[i]
495		if c == '\n' {
496			line++
497			i++
498			continue
499		}
500		if skip, n := skipNonCode(code[i:], s); skip {
501			for _, r := range code[i : i+n] {
502				if r == '\n' {
503					line++
504				}
505			}
506			i += n
507			continue
508		}
509		switch c {
510		case '(', '[', '{':
511			stack = append(stack, c)
512		case ')', ']', '}':
513			if len(stack) == 0 || stack[len(stack)-1] != pair[c] {
514				return fmt.Sprintf("bracket mismatch on line %d, a %q closes nothing", line, string(c))
515			}
516			stack = stack[:len(stack)-1]
517		}
518		i++
519	}
520	if len(stack) > 0 {
521		return fmt.Sprintf("%d bracket%s never closed, so the block is incomplete",
522			len(stack), map[bool]string{true: "", false: "s"}[len(stack) == 1])
523	}
524	return ""
525}
526
527// skipNonCode reports how many bytes to skip when the text starts a comment or
528// a string.
529func skipNonCode(s string, sc scan) (bool, int) {
530	for _, lc := range sc.lineComment {
531		if strings.HasPrefix(s, lc) {
532			if n := strings.IndexByte(s, '\n'); n >= 0 {
533				return true, n
534			}
535			return true, len(s)
536		}
537	}
538	if sc.blockOpen != "" && strings.HasPrefix(s, sc.blockOpen) {
539		if n := strings.Index(s[len(sc.blockOpen):], sc.blockClose); n >= 0 {
540			return true, len(sc.blockOpen) + n + len(sc.blockClose)
541		}
542		return true, len(s)
543	}
544	for _, t := range sc.triples {
545		if strings.HasPrefix(s, t) {
546			if n := strings.Index(s[len(t):], t); n >= 0 {
547				return true, len(t) + n + len(t)
548			}
549			return true, len(s)
550		}
551	}
552	for _, q := range sc.quotes {
553		if !strings.HasPrefix(s, q) {
554			continue
555		}
556		for i := len(q); i < len(s); i++ {
557			if sc.escape && s[i] == '\\' {
558				i++
559				continue
560			}
561			// An unterminated string ends at the newline rather than eating
562			// the whole file, which keeps one bad quote from hiding every
563			// bracket after it.
564			if s[i] == '\n' {
565				return true, i
566			}
567			if strings.HasPrefix(s[i:], q) {
568				return true, i + len(q)
569			}
570		}
571		return true, len(s)
572	}
573	return false, 0
574}
575
576// fstring is an interpolated string and fslot a bare name inside one. Two
577// alternatives rather than a back reference, which RE2 does not have. Only a
578// plain identifier is read out, since an attribute, a call or a comprehension
579// has too many ways to be defined elsewhere.
580var (
581	fstring = regexp.MustCompile(`\bf"([^"\n]*)"|\bf'([^'\n]*)'`)
582	fslot   = regexp.MustCompile(`\{([A-Za-z_]\w*)\}`)
583)
584
585// undefinedInFString catches a name that appears only inside an f-string, which
586// is a NameError the moment the line runs.
587//
588// It is the shape of mistake a model makes filling in a connection string it
589// half remembers, and unlike a missing import nothing else in the file hints
590// at it.
591func undefinedInFString(code string) string {
592	for _, m := range fstring.FindAllStringSubmatch(code, -1) {
593		for _, slot := range fslot.FindAllStringSubmatch(m[1]+m[2], -1) {
594			name := slot[1]
595			if pyBuiltin[name] {
596				continue
597			}
598			if strings.Count(code, name) > 1 {
599				continue
600			}
601			return "uses " + name + " in an f-string and never defines it, so it fails as soon as it runs"
602		}
603	}
604	return ""
605}
606
607var pyBuiltin = map[string]bool{
608	"self": true, "cls": true, "True": true, "False": true, "None": true,
609	"e": true, "i": true, "x": true,
610}
611
612// pythonIndent catches the one whitespace mistake that actually stops Python
613// running, a file mixing tabs and spaces for its indentation.
614func pythonIndent(code string) string {
615	tabs, spaces := false, false
616	for _, l := range strings.Split(code, "\n") {
617		if strings.TrimSpace(l) == "" {
618			continue
619		}
620		switch {
621		case strings.HasPrefix(l, "\t"):
622			tabs = true
623		case strings.HasPrefix(l, " "):
624			spaces = true
625		}
626	}
627	if tabs && spaces {
628		return "mixes tabs and spaces for indentation, which Python rejects"
629	}
630	return ""
631}
632
633func yamlTabs(code string) string {
634	for i, l := range strings.Split(code, "\n") {
635		if strings.HasPrefix(l, "\t") {
636			return fmt.Sprintf("line %d is indented with a tab, which YAML does not allow", i+1)
637		}
638	}
639	return ""
640}
641
642var (
643	htmlTag  = regexp.MustCompile(`(?is)<(/?)([a-z][a-z0-9-]*)\b[^>]*?(/?)>`)
644	voidTags = map[string]bool{
645		"area": true, "base": true, "br": true, "col": true, "embed": true,
646		"hr": true, "img": true, "input": true, "link": true, "meta": true,
647		"param": true, "source": true, "track": true, "wbr": true,
648		"!doctype": true,
649	}
650)
651
652// htmlBalance is a tag counter rather than a parser. It only has to catch a
653// page that stops halfway, which is what a truncated answer looks like.
654func htmlBalance(code string) string {
655	var stack []string
656	for _, m := range htmlTag.FindAllStringSubmatch(code, -1) {
657		closing, name, self := m[1] == "/", strings.ToLower(m[2]), m[3] == "/"
658		if voidTags[name] || self {
659			continue
660		}
661		if !closing {
662			stack = append(stack, name)
663			continue
664		}
665		// Unwind to the matching open rather than demanding the top of the
666		// stack, since a real page leaves the odd <p> and <li> unclosed and
667		// that is legal HTML.
668		found := -1
669		for i := len(stack) - 1; i >= 0; i-- {
670			if stack[i] == name {
671				found = i
672				break
673			}
674		}
675		if found < 0 {
676			return fmt.Sprintf("closes </%s> without opening it", name)
677		}
678		stack = stack[:found]
679	}
680	for _, name := range stack {
681		switch name {
682		case "html", "head", "body", "script", "style", "div", "svg", "table":
683			return fmt.Sprintf("<%s> is never closed, so the page is incomplete", name)
684		}
685	}
686	return ""
687}
688
689var (
690	scriptSrc  = regexp.MustCompile(`(?i)<script[^>]+src\s*=`)
691	newGlobal  = regexp.MustCompile(`\bnew\s+(?:window\.)?([A-Z][A-Za-z0-9_]*)\s*[.(]`)
692	callGlobal = regexp.MustCompile(`(?:^|[^\w.])(?:window\.)?([A-Z][A-Za-z0-9_]*)\.[a-z]\w*\s*\(`)
693)
694
695// browserGlobals is what a page already has without loading anything.
696var browserGlobals = map[string]bool{
697	"Array": true, "Boolean": true, "Date": true, "Error": true, "Function": true,
698	"Image": true, "Intl": true, "JSON": true, "Map": true, "Math": true,
699	"Number": true, "Object": true, "Promise": true, "Proxy": true, "Reflect": true,
700	"RegExp": true, "Set": true, "String": true, "Symbol": true, "URL": true,
701	"URLSearchParams": true, "WeakMap": true, "WeakSet": true, "XMLHttpRequest": true,
702	"Audio": true, "Blob": true, "FormData": true, "Headers": true, "Request": true,
703	"Response": true, "AbortController": true, "Event": true, "CustomEvent": true,
704	"Worker": true, "WebSocket": true, "FileReader": true, "TextEncoder": true,
705	"TextDecoder": true, "BigInt": true, "Notification": true, "ResizeObserver": true,
706	"IntersectionObserver": true, "MutationObserver": true, "OffscreenCanvas": true,
707	"Path2D": true, "DOMParser": true, "Option": true,
708}
709
710// missingLibrary catches a page calling into a library it never loads.
711//
712// The check only runs on a page with no external script at all, which is the
713// case it can be sure about: the model was asked for a map, could not find a
714// real library in the passages, and called `new SatMeteo.Map(...)` off a
715// website that has no such thing. The page renders blank and looks finished.
716func missingLibrary(code string) string {
717	if scriptSrc.MatchString(code) {
718		return ""
719	}
720	for _, m := range append(newGlobal.FindAllStringSubmatch(code, -1), callGlobal.FindAllStringSubmatch(code, -1)...) {
721		name := m[1]
722		if browserGlobals[name] || definedIn(code, name) {
723			continue
724		}
725		return fmt.Sprintf("uses %s but loads no script that defines it, so the page will not do anything", name)
726	}
727	return ""
728}
729
730func definedIn(code, name string) bool {
731	for _, form := range []string{
732		"var " + name, "let " + name, "const " + name, "function " + name,
733		"class " + name, "window." + name + " =", name + " =",
734	} {
735		if strings.Contains(code, form) {
736			return true
737		}
738	}
739	return false
740}
741
742var dockerInstructions = map[string]bool{
743	"FROM": true, "RUN": true, "CMD": true, "LABEL": true, "MAINTAINER": true,
744	"EXPOSE": true, "ENV": true, "ADD": true, "COPY": true, "ENTRYPOINT": true,
745	"VOLUME": true, "USER": true, "WORKDIR": true, "ARG": true, "ONBUILD": true,
746	"STOPSIGNAL": true, "HEALTHCHECK": true, "SHELL": true,
747}
748
749// dockerfileSyntax checks the two things that are always true of one: it opens
750// on FROM, and every line is an instruction docker knows.
751func dockerfileSyntax(code string) string {
752	seenFrom := false
753	continued := false
754	for i, raw := range strings.Split(code, "\n") {
755		l := strings.TrimSpace(raw)
756		wasContinued := continued
757		continued = strings.HasSuffix(l, "\\")
758		if l == "" || strings.HasPrefix(l, "#") || wasContinued {
759			continue
760		}
761		word := strings.ToUpper(strings.Fields(l)[0])
762		if !dockerInstructions[word] {
763			return fmt.Sprintf("line %d starts with %q, which is not a Dockerfile instruction", i+1, strings.Fields(l)[0])
764		}
765		if word == "FROM" {
766			seenFrom = true
767		}
768		if !seenFrom && word != "ARG" {
769			return fmt.Sprintf("line %d runs before any FROM, so there is no image to build on", i+1)
770		}
771	}
772	if !seenFrom {
773		return "has no FROM, so there is nothing to build"
774	}
775	return ""
776}
777
778// unusedConstants names a config knob the code defines and never reads.
779//
780// A model writing from fragments puts CACHE_AGE at the top because every
781// example it saw had one, and then never uses it, which reads as a setting a
782// person can change and is not. Upper case only, since a lower case name has
783// too many ways to be used indirectly.
784var constLine = regexp.MustCompile(`(?m)^([A-Z][A-Z0-9_]{2,})\s*(?::[^=\n]+)?=`)
785
786func unusedConstants(blocks []CodeBlock) []string {
787	var out []string
788	for _, b := range blocks {
789		switch normalLang(b.Lang, b.File) {
790		case "python", "javascript", "go":
791		default:
792			continue
793		}
794		for _, m := range constLine.FindAllStringSubmatch(b.Code, -1) {
795			name := m[1]
796			if strings.Count(b.Code, name) > 1 {
797				continue
798			}
799			where := b.File
800			if where == "" {
801				where = "the " + b.Lang + " block"
802			}
803			out = append(out, fmt.Sprintf("%s sets %s and never uses it, so changing it does nothing", where, name))
804		}
805	}
806	return out
807}
808
809// codeWarnings turns failed checks into the lines shown above the answer. Only
810// the failures, since a reader does not need telling that six files were fine
811// when the panel below already says so.
812func codeWarnings(checks []CodeCheck) []string {
813	var out []string
814	for _, c := range checks {
815		if c.OK {
816			continue
817		}
818		name := c.File
819		if name == "" {
820			name = "the " + c.Lang + " block"
821		}
822		out = append(out, fmt.Sprintf("%s %s", name, c.Note))
823	}
824	return out
825}
826
827// codeWeight scores a page for a code question. A page that shows code is
828// worth more than one that talks about it, and the docs are worth more than a
829// tutorial farm, which is most of what a search for a library name returns.
830func codeWeight(p *Page) int {
831	if p == nil {
832		return 0
833	}
834	score := strings.Count(p.Markdown, "\n```")
835	if score > 6 {
836		score = 6
837	}
838	host := strings.ToLower(p.Site)
839	switch {
840	case strings.HasPrefix(host, "docs."), strings.Contains(host, "readthedocs"),
841		strings.Contains(host, "developer."), strings.HasSuffix(host, ".dev"),
842		strings.Contains(host, "github.com"), strings.Contains(host, "gitlab.com"),
843		strings.Contains(host, "mozilla.org"), strings.Contains(host, "pkg.go.dev"),
844		strings.Contains(host, "docker.com"), strings.Contains(host, "python.org"),
845		strings.Contains(host, "ibm.com"), strings.Contains(host, "microsoft.com"),
846		strings.Contains(host, "stackoverflow.com"):
847		score += 4
848	}
849	return score
850}
851
852// codeFailed says whether the checks found something a second search could fix.
853// A file that does not parse and a package that is not in its registry are both
854// evidence the pages found did not show how this is really written.
855func codeFailed(checks []CodeCheck, deps []Dependency) bool {
856	for _, c := range checks {
857		if !c.OK && !c.Truncated {
858			return true
859		}
860	}
861	for _, d := range deps {
862		if d.Checked && !d.Found {
863			return true
864		}
865	}
866	return false
867}
868
869// codeHint tells the second plan what went wrong, in the terms the first one
870// got wrong, so it searches for the library rather than rewording the answer.
871func codeHint(checks []CodeCheck, deps []Dependency) string {
872	var bad []string
873	for _, c := range checks {
874		if !c.OK {
875			name := c.File
876			if name == "" {
877				name = "the " + c.Lang + " block"
878			}
879			bad = append(bad, name+" "+c.Note)
880		}
881	}
882	for _, d := range deps {
883		if d.Checked && !d.Found {
884			bad = append(bad, "it used the package "+d.Name+", which does not exist on "+ecoName(d.Eco))
885		}
886	}
887	return "A previous attempt wrote code with these problems: " + strings.Join(bad, "; ") +
888		". Search for the official documentation and a working example of the library or tool this actually needs."
889}