lazycodex-ai 5.1.54 → 5.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +29 -18
- package/dist/cli-node/index.js +29 -18
- package/package.json +2 -1
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +2 -0
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/omo-codex/plugin/skills/programming/SKILL.md +2 -0
- package/packages/omo-codex/plugin/skills/programming/references/testing/README.md +2 -2
- package/packages/omo-codex/plugin/skills/programming/scripts/typescript/check-test-junk-rules.ts +140 -0
- package/packages/omo-codex/plugin/skills/programming/scripts/typescript/check-test-junk.ts +148 -0
- package/packages/omo-codex/plugin/skills/programming/scripts/typescript/mutation-proof.ts +179 -0
- package/packages/omo-codex/plugin/skills/remove-ai-slops/SKILL.md +32 -20
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +3 -1
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +4 -2
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/shared-skills/skills/programming/SKILL.md +2 -0
- package/packages/shared-skills/skills/programming/references/testing/README.md +2 -2
- package/packages/shared-skills/skills/programming/scripts/typescript/check-test-junk-rules.ts +140 -0
- package/packages/shared-skills/skills/programming/scripts/typescript/check-test-junk.test.ts +125 -0
- package/packages/shared-skills/skills/programming/scripts/typescript/check-test-junk.ts +148 -0
- package/packages/shared-skills/skills/programming/scripts/typescript/mutation-proof.test.ts +43 -0
- package/packages/shared-skills/skills/programming/scripts/typescript/mutation-proof.ts +179 -0
- package/packages/shared-skills/skills/remove-ai-slops/SKILL.md +32 -20
- package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +3 -1
- package/packages/shared-skills/skills/ulw-research/scripts/source-integrity.test.ts +39 -2
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { extname } from "node:path"
|
|
2
|
+
|
|
3
|
+
export type RuleId =
|
|
4
|
+
| "source-grep"
|
|
5
|
+
| "prose-pin"
|
|
6
|
+
| "fixed-sleep"
|
|
7
|
+
| "focused"
|
|
8
|
+
| "skipped"
|
|
9
|
+
| "self-compare"
|
|
10
|
+
| "lower-bound"
|
|
11
|
+
|
|
12
|
+
export type Language = "typescript" | "python" | "rust" | "go"
|
|
13
|
+
|
|
14
|
+
export type ReadKind = "prose" | "source"
|
|
15
|
+
|
|
16
|
+
export type LineRule = {
|
|
17
|
+
readonly rule: RuleId
|
|
18
|
+
readonly languages: readonly Language[]
|
|
19
|
+
readonly pattern: RegExp
|
|
20
|
+
readonly message: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const RULE_IDS: readonly RuleId[] = [
|
|
24
|
+
"source-grep",
|
|
25
|
+
"prose-pin",
|
|
26
|
+
"fixed-sleep",
|
|
27
|
+
"focused",
|
|
28
|
+
"skipped",
|
|
29
|
+
"self-compare",
|
|
30
|
+
"lower-bound",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export function languageOf(file: string): Language | undefined {
|
|
34
|
+
switch (extname(file)) {
|
|
35
|
+
case ".ts":
|
|
36
|
+
case ".tsx":
|
|
37
|
+
case ".mts":
|
|
38
|
+
case ".cts":
|
|
39
|
+
case ".js":
|
|
40
|
+
case ".jsx":
|
|
41
|
+
case ".mjs":
|
|
42
|
+
case ".cjs":
|
|
43
|
+
return "typescript"
|
|
44
|
+
case ".py":
|
|
45
|
+
return "python"
|
|
46
|
+
case ".rs":
|
|
47
|
+
return "rust"
|
|
48
|
+
case ".go":
|
|
49
|
+
return "go"
|
|
50
|
+
default:
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SLEEP_MESSAGE = "fixed sleep: wait on the event or state change, not on the clock"
|
|
56
|
+
const SKIP_MESSAGE = "skipped test: fix the test or delete it with its proof; never park it"
|
|
57
|
+
const LOWER_BOUND_MESSAGE = "lower bound on a cardinality: removing one item stays green; pin the exact set"
|
|
58
|
+
|
|
59
|
+
export const LINE_RULES: readonly LineRule[] = [
|
|
60
|
+
{
|
|
61
|
+
rule: "fixed-sleep",
|
|
62
|
+
languages: ["typescript"],
|
|
63
|
+
pattern: /\bsetTimeout\s*\(\s*(resolve|res|r|done|cb)\s*,\s*[1-9]|\bawait\s+(Bun\.)?sleep\s*\(\s*[1-9]|new Promise\s*\(\s*\(?\s*\w*\s*\)?\s*=>\s*setTimeout\s*\(\s*\w+\s*,\s*[1-9]/,
|
|
64
|
+
message: SLEEP_MESSAGE,
|
|
65
|
+
},
|
|
66
|
+
{ rule: "fixed-sleep", languages: ["python"], pattern: /\btime\.sleep\s*\(|\bawait\s+(asyncio|anyio|trio)\.sleep\s*\(\s*[0-9]/, message: SLEEP_MESSAGE },
|
|
67
|
+
{ rule: "fixed-sleep", languages: ["rust"], pattern: /\bthread::sleep\s*\(|\btokio::time::sleep\s*\(/, message: SLEEP_MESSAGE },
|
|
68
|
+
{ rule: "fixed-sleep", languages: ["go"], pattern: /\btime\.Sleep\s*\(/, message: SLEEP_MESSAGE },
|
|
69
|
+
{
|
|
70
|
+
rule: "focused",
|
|
71
|
+
languages: ["typescript"],
|
|
72
|
+
pattern: /\b(test|it|describe)\.only\s*\(/,
|
|
73
|
+
message: "focused test: the rest of the suite is skipped silently",
|
|
74
|
+
},
|
|
75
|
+
{ rule: "skipped", languages: ["typescript"], pattern: /\b(test|it|describe)\.skip\s*\(|\b(xit|xdescribe|xtest)\s*\(/, message: SKIP_MESSAGE },
|
|
76
|
+
{ rule: "skipped", languages: ["python"], pattern: /@pytest\.mark\.skip\b|\bpytest\.skip\s*\(|\bunittest\.skip\b/, message: SKIP_MESSAGE },
|
|
77
|
+
{ rule: "skipped", languages: ["rust"], pattern: /#\[ignore\b/, message: SKIP_MESSAGE },
|
|
78
|
+
{ rule: "skipped", languages: ["go"], pattern: /\bt\.Skip(f|Now)?\s*\(/, message: SKIP_MESSAGE },
|
|
79
|
+
{
|
|
80
|
+
rule: "lower-bound",
|
|
81
|
+
languages: ["typescript"],
|
|
82
|
+
pattern: /\.(length|size|count)\s*\)\s*\.(toBeGreaterThan\s*\(\s*[1-9]|toBeGreaterThanOrEqual\s*\(\s*([2-9]|\d{2,}))/,
|
|
83
|
+
message: LOWER_BOUND_MESSAGE,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
rule: "lower-bound",
|
|
87
|
+
languages: ["python"],
|
|
88
|
+
pattern: /\bassert\s+len\s*\(.+\)\s*(>\s*[1-9]|>=\s*([2-9]|\d{2,}))/,
|
|
89
|
+
message: LOWER_BOUND_MESSAGE,
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
export const SELF_COMPARE: Readonly<Record<Language, RegExp>> = {
|
|
94
|
+
typescript: /\bexpect\s*\(\s*([^()]+?)\s*\)\s*\.(toBe|toEqual|toStrictEqual)\s*\(\s*\1\s*\)/,
|
|
95
|
+
python: /\bassert\s+([A-Za-z_][\w.]*)\s*==\s*\1\s*(#|$)/,
|
|
96
|
+
rust: /\bassert_eq!\s*\(\s*([^,()]+?)\s*,\s*\1\s*\)/,
|
|
97
|
+
go: /\b(Equal|Exactly)\s*\(\s*t\s*,\s*([^,()]+?)\s*,\s*\2\s*\)/,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|rs|go)$/
|
|
101
|
+
const PROSE_EXTENSIONS = /\.(md|mdc|txt|prompt)$/
|
|
102
|
+
const READ_ASSIGNMENT = /\b(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:\w+\.)?(?:readFileSync|readFile|readTextSync|Bun\.file|open|read_text|read)\s*\(([^\n]*)$/
|
|
103
|
+
const PYTHON_READ_ASSIGNMENT = /^\s*(\w+)\s*=\s*(?:\w+\.)?(?:open|Path|read_text)\s*\(([^\n]*)$/
|
|
104
|
+
const STRING_LITERAL = /["'`]([^"'`]+)["'`]/g
|
|
105
|
+
|
|
106
|
+
function readKindOf(callArguments: string): ReadKind | undefined {
|
|
107
|
+
for (const literal of callArguments.matchAll(STRING_LITERAL)) {
|
|
108
|
+
const value = literal[1] ?? ""
|
|
109
|
+
if (PROSE_EXTENSIONS.test(value)) return "prose"
|
|
110
|
+
if (SOURCE_EXTENSIONS.test(value)) return "source"
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function collectReadVariables(lines: readonly string[], language: Language): ReadonlyMap<string, ReadKind> {
|
|
116
|
+
const variables = new Map<string, ReadKind>()
|
|
117
|
+
const pattern = language === "python" ? PYTHON_READ_ASSIGNMENT : READ_ASSIGNMENT
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
const match = pattern.exec(line)
|
|
120
|
+
if (match === null) continue
|
|
121
|
+
const kind = readKindOf(match[2] ?? "")
|
|
122
|
+
if (kind !== undefined && match[1] !== undefined) variables.set(match[1], kind)
|
|
123
|
+
}
|
|
124
|
+
return variables
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function greppedVariable(line: string, language: Language): string | undefined {
|
|
128
|
+
if (language === "python") {
|
|
129
|
+
return /\bassert\s+(?:not\s+)?["'][^"']*["']\s+(?:not\s+)?in\s+(\w+)/.exec(line)?.[1]
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
/\bexpect\s*\(\s*(\w+)\s*\)\s*(?:\.not)?\.(?:toContain|toMatch|toMatchSnapshot|toMatchInlineSnapshot|toInclude|toStartWith|toEndWith)\s*\(/.exec(line)?.[1] ??
|
|
133
|
+
/\bexpect\s*\(\s*(\w+)\.(?:includes|match|indexOf|search)\s*\(/.exec(line)?.[1]
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const READ_RULE_MESSAGES: Readonly<Record<ReadKind, { readonly rule: RuleId; readonly message: string }>> = {
|
|
138
|
+
prose: { rule: "prose-pin", message: "wording of a prompt/skill/doc is pinned; assert a machine-consumed value or write no test" },
|
|
139
|
+
source: { rule: "source-grep", message: "a source file is grepped as text; drive the module and assert behavior" },
|
|
140
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
|
|
6
|
+
import type { Finding, RuleId } from "./check-test-junk"
|
|
7
|
+
|
|
8
|
+
const script = join(import.meta.dir, "check-test-junk.ts")
|
|
9
|
+
let root = ""
|
|
10
|
+
|
|
11
|
+
async function run(args: readonly string[]): Promise<{ readonly code: number; readonly findings: readonly Finding[] }> {
|
|
12
|
+
const child = Bun.spawn([process.execPath, script, "--json", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" })
|
|
13
|
+
const [stdout, code] = await Promise.all([new Response(child.stdout).text(), child.exited])
|
|
14
|
+
const parsed: { readonly findings: readonly Finding[] } = JSON.parse(stdout)
|
|
15
|
+
return { code, findings: parsed.findings }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const JUNK_TS = `import { readFileSync } from "node:fs"
|
|
19
|
+
test("source grep", () => {
|
|
20
|
+
const source = readFileSync("src/parser.ts", "utf8")
|
|
21
|
+
expect(source).toContain("export function parse")
|
|
22
|
+
})
|
|
23
|
+
test("prose pin", () => {
|
|
24
|
+
const skill = readFileSync("SKILL.md", "utf8")
|
|
25
|
+
expect(skill).toContain("You are a senior engineer")
|
|
26
|
+
})
|
|
27
|
+
test("sleeps", async () => {
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
29
|
+
})
|
|
30
|
+
test.only("focused", () => {})
|
|
31
|
+
it.skip("parked", () => {})
|
|
32
|
+
test("self compare", () => {
|
|
33
|
+
expect(config.prompt).toEqual(config.prompt)
|
|
34
|
+
})
|
|
35
|
+
test("lower bound", () => {
|
|
36
|
+
expect(masked.length).toBeGreaterThanOrEqual(3)
|
|
37
|
+
})
|
|
38
|
+
test("opted out", async () => {
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 50)) // test-junk-ok: fixed-sleep debounce window is the behavior under test
|
|
40
|
+
})
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
const CLEAN_TS = `import { readFileSync } from "node:fs"
|
|
44
|
+
test("parses a date when the input has a timezone", () => {
|
|
45
|
+
expect(parse("2026-01-01T00:00:00Z")).toEqual({ year: 2026 })
|
|
46
|
+
})
|
|
47
|
+
test("the packaged skill equals its source", () => {
|
|
48
|
+
const source = readFileSync("skills/x/SKILL.md", "utf8")
|
|
49
|
+
const packaged = readFileSync("dist/skills/x/SKILL.md", "utf8")
|
|
50
|
+
expect(packaged).toBe(source)
|
|
51
|
+
})
|
|
52
|
+
test("the bundle keeps the runtime entry", () => {
|
|
53
|
+
const bundle = readFileSync("dist/index.js", "utf8")
|
|
54
|
+
expect(bundle.length).toBeGreaterThan(0)
|
|
55
|
+
expect(parseExports(bundle)).toEqual(["main"])
|
|
56
|
+
})
|
|
57
|
+
test("yields one tick before asserting", async () => {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
59
|
+
expect(flushed).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
const JUNK_PY = `import time
|
|
64
|
+
def test_sleeps():
|
|
65
|
+
time.sleep(1)
|
|
66
|
+
def test_lower():
|
|
67
|
+
assert len(items) >= 2
|
|
68
|
+
def test_lower_nested():
|
|
69
|
+
assert len(select(items)) > 1
|
|
70
|
+
def test_prose():
|
|
71
|
+
body = Path("SKILL.md").read_text()
|
|
72
|
+
assert "You are" in body
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
const JUNK_GO = `func TestSleeps(t *testing.T) {
|
|
76
|
+
\ttime.Sleep(100 * time.Millisecond)
|
|
77
|
+
\tt.Skip("flaky")
|
|
78
|
+
}
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
describe("#given test files with mechanically detectable junk", () => {
|
|
82
|
+
beforeAll(() => {
|
|
83
|
+
root = mkdtempSync(join(process.env.RUN_TMP ?? tmpdir(), "check-test-junk-"))
|
|
84
|
+
mkdirSync(join(root, "src"), { recursive: true })
|
|
85
|
+
writeFileSync(join(root, "src", "junk.test.ts"), JUNK_TS)
|
|
86
|
+
writeFileSync(join(root, "src", "clean.test.ts"), CLEAN_TS)
|
|
87
|
+
writeFileSync(join(root, "src", "test_junk.py"), JUNK_PY)
|
|
88
|
+
writeFileSync(join(root, "src", "junk_test.go"), JUNK_GO)
|
|
89
|
+
writeFileSync(join(root, "src", "parser.ts"), "export const parse = () => readFileSync('x.ts')\n")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
93
|
+
|
|
94
|
+
test("#when the TypeScript fixture is scanned #then every class is reported at its line and the opted-out line is not", async () => {
|
|
95
|
+
const { code, findings } = await run(["src/junk.test.ts"])
|
|
96
|
+
const byRule = new Map<RuleId, number[]>()
|
|
97
|
+
for (const finding of findings) byRule.set(finding.rule, [...(byRule.get(finding.rule) ?? []), finding.line])
|
|
98
|
+
expect(code).toBe(1)
|
|
99
|
+
expect(byRule.get("source-grep")).toEqual([4])
|
|
100
|
+
expect(byRule.get("prose-pin")).toEqual([8])
|
|
101
|
+
expect(byRule.get("fixed-sleep")).toEqual([11])
|
|
102
|
+
expect(byRule.get("focused")).toEqual([13])
|
|
103
|
+
expect(byRule.get("skipped")).toEqual([14])
|
|
104
|
+
expect(byRule.get("self-compare")).toEqual([16])
|
|
105
|
+
expect(byRule.get("lower-bound")).toEqual([19])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("#when Python and Go fixtures are scanned #then their sleep, skip and lower-bound forms are reported", async () => {
|
|
109
|
+
const { findings } = await run(["src/test_junk.py", "src/junk_test.go"])
|
|
110
|
+
expect(findings.map((finding) => [finding.file.split("/").at(-1), finding.line, finding.rule])).toEqual([
|
|
111
|
+
["test_junk.py", 3, "fixed-sleep"],
|
|
112
|
+
["test_junk.py", 5, "lower-bound"],
|
|
113
|
+
["test_junk.py", 7, "lower-bound"],
|
|
114
|
+
["test_junk.py", 10, "prose-pin"],
|
|
115
|
+
["junk_test.go", 2, "fixed-sleep"],
|
|
116
|
+
["junk_test.go", 3, "skipped"],
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("#when a clean test file and a production file are scanned #then nothing is reported and the exit code is 0", async () => {
|
|
121
|
+
const { code, findings } = await run(["src/clean.test.ts", "src/parser.ts"])
|
|
122
|
+
expect(findings).toEqual([])
|
|
123
|
+
expect(code).toBe(0)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Flag test files for the junk classes a regex can see (the rest need a reader):
|
|
4
|
+
*
|
|
5
|
+
* source-grep - a value read from a source file (.ts/.js/.py/.rs/.go/...) is grepped
|
|
6
|
+
* (toContain / toMatch / includes / `in`); byte equality between two reads is not flagged
|
|
7
|
+
* prose-pin - a value read from a prompt/skill/markdown file has its wording pinned
|
|
8
|
+
* (toContain / not.toContain / toMatch / toMatchSnapshot / `in`)
|
|
9
|
+
* fixed-sleep - setTimeout-as-wait, sleep(N), time.sleep, thread::sleep, time.Sleep
|
|
10
|
+
* focused - test.only / it.only / describe.only (a focused test silently skips the suite)
|
|
11
|
+
* skipped - .skip / xit / xdescribe / @pytest.mark.skip / #[ignore] / t.Skip
|
|
12
|
+
* self-compare - expect(x).toBe(x) / assert x == x / assert_eq!(x, x)
|
|
13
|
+
* lower-bound - toBeGreaterThan(N>=1) / toBeGreaterThanOrEqual(N>=2) on a length/size/count
|
|
14
|
+
* (removing one item stays green; a plain non-empty check is not flagged)
|
|
15
|
+
*
|
|
16
|
+
* Opt out per line with a trailing comment naming the rule and the reason:
|
|
17
|
+
* // test-junk-ok: fixed-sleep debounce window is the behavior under test
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* bun run scripts/typescript/check-test-junk.ts [--json] <file-or-dir>...
|
|
21
|
+
*
|
|
22
|
+
* Exit codes: 0 no findings, 1 findings, 2 input error.
|
|
23
|
+
* Each class is explained in references/testing/junk-classes.md; a finding here is a candidate
|
|
24
|
+
* to prove with references/testing/README.md "test your test", not an automatic deletion.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
28
|
+
import { basename, extname, join } from "node:path"
|
|
29
|
+
import process from "node:process"
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
collectReadVariables,
|
|
33
|
+
greppedVariable,
|
|
34
|
+
languageOf,
|
|
35
|
+
LINE_RULES,
|
|
36
|
+
READ_RULE_MESSAGES,
|
|
37
|
+
RULE_IDS,
|
|
38
|
+
SELF_COMPARE,
|
|
39
|
+
type RuleId,
|
|
40
|
+
} from "./check-test-junk-rules"
|
|
41
|
+
|
|
42
|
+
export type { RuleId } from "./check-test-junk-rules"
|
|
43
|
+
|
|
44
|
+
export type Finding = {
|
|
45
|
+
readonly file: string
|
|
46
|
+
readonly line: number
|
|
47
|
+
readonly rule: RuleId
|
|
48
|
+
readonly message: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const EXCLUDED_DIRS: ReadonlySet<string> = new Set([".git", "node_modules", "dist", "build", "target", "__pycache__", ".venv", "vendor"])
|
|
52
|
+
|
|
53
|
+
const TEST_FILE_PATTERNS: readonly RegExp[] = [
|
|
54
|
+
/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/,
|
|
55
|
+
/_test\.go$/,
|
|
56
|
+
/(^|\/)test_[^/]*\.py$/,
|
|
57
|
+
/(^|\/)[^/]*_test\.py$/,
|
|
58
|
+
/\.rs$/,
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const OPT_OUT = /test-junk-ok:\s*([a-z-]+)/
|
|
62
|
+
|
|
63
|
+
function isTestFile(file: string): boolean {
|
|
64
|
+
if (!TEST_FILE_PATTERNS.some((pattern) => pattern.test(file))) return false
|
|
65
|
+
// Rust unit tests live inside source files; only files declaring a test module count.
|
|
66
|
+
return extname(file) !== ".rs" || /#\[cfg\(test\)\]|#\[test\]/.test(readFileSync(file, "utf8"))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function* walk(root: string): Generator<string> {
|
|
70
|
+
if (statSync(root).isFile()) {
|
|
71
|
+
yield root
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (!EXCLUDED_DIRS.has(entry.name)) yield* walk(join(root, entry.name))
|
|
77
|
+
} else if (entry.isFile()) {
|
|
78
|
+
yield join(root, entry.name)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function optedOut(line: string, rule: RuleId): boolean {
|
|
84
|
+
const match = OPT_OUT.exec(line)
|
|
85
|
+
return match !== null && match[1] === rule
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scanFile(file: string): readonly Finding[] {
|
|
89
|
+
const language = languageOf(file)
|
|
90
|
+
if (language === undefined) return []
|
|
91
|
+
const lines = readFileSync(file, "utf8").split("\n")
|
|
92
|
+
const readVariables = collectReadVariables(lines, language)
|
|
93
|
+
const findings: Finding[] = []
|
|
94
|
+
const report = (line: number, rule: RuleId, message: string): void => {
|
|
95
|
+
if (!optedOut(lines[line - 1] ?? "", rule)) findings.push({ file, line, rule, message })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
lines.forEach((source, index) => {
|
|
99
|
+
const line = index + 1
|
|
100
|
+
for (const rule of LINE_RULES) {
|
|
101
|
+
if (rule.languages.includes(language) && rule.pattern.test(source)) report(line, rule.rule, rule.message)
|
|
102
|
+
}
|
|
103
|
+
if (SELF_COMPARE[language].test(source)) {
|
|
104
|
+
report(line, "self-compare", "the expected value is the value under test; derive it from the input")
|
|
105
|
+
}
|
|
106
|
+
const grepped = greppedVariable(source, language)
|
|
107
|
+
const kind = grepped === undefined ? undefined : readVariables.get(grepped)
|
|
108
|
+
if (kind !== undefined) report(line, READ_RULE_MESSAGES[kind].rule, READ_RULE_MESSAGES[kind].message)
|
|
109
|
+
})
|
|
110
|
+
return findings
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scan(paths: readonly string[]): readonly Finding[] {
|
|
114
|
+
const findings: Finding[] = []
|
|
115
|
+
for (const root of paths) {
|
|
116
|
+
for (const file of walk(root)) {
|
|
117
|
+
if (isTestFile(file)) findings.push(...scanFile(file))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return findings
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function main(argv: readonly string[]): number {
|
|
124
|
+
const json = argv.includes("--json")
|
|
125
|
+
const paths = argv.filter((arg) => arg !== "--json")
|
|
126
|
+
if (paths.length === 0) {
|
|
127
|
+
console.error(`usage: ${basename(import.meta.path)} [--json] <file-or-dir>...`)
|
|
128
|
+
return 2
|
|
129
|
+
}
|
|
130
|
+
for (const path of paths) {
|
|
131
|
+
try {
|
|
132
|
+
statSync(path)
|
|
133
|
+
} catch {
|
|
134
|
+
console.error(`error: ${path} does not exist`)
|
|
135
|
+
return 2
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const findings = scan(paths)
|
|
139
|
+
if (json) {
|
|
140
|
+
console.log(JSON.stringify({ findings, rules: RULE_IDS }, null, 2))
|
|
141
|
+
} else {
|
|
142
|
+
for (const finding of findings) console.log(`${finding.file}:${finding.line} ${finding.rule} ${finding.message}`)
|
|
143
|
+
console.log(findings.length === 0 ? "check-test-junk: no findings" : `check-test-junk: ${findings.length} finding(s)`)
|
|
144
|
+
}
|
|
145
|
+
return findings.length === 0 ? 0 : 1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
|
|
6
|
+
import { MutationError, runMutation } from "./mutation-proof"
|
|
7
|
+
|
|
8
|
+
let root = ""
|
|
9
|
+
const OWNER = "export const add = (a: number, b: number): number => a + b\n"
|
|
10
|
+
|
|
11
|
+
describe("#given a production owner and a test that exercises it", () => {
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
root = mkdtempSync(join(process.env.RUN_TMP ?? tmpdir(), "mutation-proof-"))
|
|
14
|
+
writeFileSync(join(root, "owner.ts"), OWNER)
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(root, "owner.test.ts"),
|
|
17
|
+
'import { expect, test } from "bun:test"\nimport { add } from "./owner"\ntest("adds", () => { expect(add(1, 2)).toBe(3) })\n',
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }))
|
|
22
|
+
|
|
23
|
+
test("#when the owner is broken #then the test goes red and the owner is restored byte for byte", async () => {
|
|
24
|
+
const result = await runMutation({
|
|
25
|
+
cwd: root,
|
|
26
|
+
file: "owner.ts",
|
|
27
|
+
find: "a + b",
|
|
28
|
+
replace: "a - b",
|
|
29
|
+
tests: [{ label: "candidate", command: [process.execPath, "test", "owner.test.ts"] }],
|
|
30
|
+
})
|
|
31
|
+
expect(result.outcomes.map((outcome) => [outcome.label, outcome.verdict])).toEqual([["candidate", "red"]])
|
|
32
|
+
expect(result.restoredSha256).toBe(result.originalSha256)
|
|
33
|
+
expect(readFileSync(join(root, "owner.ts"), "utf8")).toBe(OWNER)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("#when the find text is not unique or empty #then it refuses before touching the file", async () => {
|
|
37
|
+
for (const [find, kind] of [["a", "FIND_NOT_UNIQUE"], ["", "FIND_EMPTY"]] as const) {
|
|
38
|
+
const attempt = runMutation({ cwd: root, file: "owner.ts", find, replace: "x", tests: [] })
|
|
39
|
+
await expect(attempt).rejects.toMatchObject({ kind })
|
|
40
|
+
}
|
|
41
|
+
expect(readFileSync(join(root, "owner.ts"), "utf8")).toBe(OWNER)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Prove that a test can fail: apply one exact-match edit to a production file, run each test
|
|
4
|
+
* command against the mutated owner, then restore the original bytes and verify their hash.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun run scripts/typescript/mutation-proof.ts --file <path> --find <text> --replace <text> \
|
|
8
|
+
* --test '<label>=<argv...>' [--test ...] [--cwd <dir>] [--json]
|
|
9
|
+
*
|
|
10
|
+
* --test takes "label=command words"; the command is split on spaces and quoting is not
|
|
11
|
+
* supported, so select a test by a pattern without spaces or run the whole file.
|
|
12
|
+
*
|
|
13
|
+
* Verdicts per test: red (exit != 0, the test caught the mutation) or green (exit 0, it did not).
|
|
14
|
+
* The owner is restored on success, on a thrown error, and on SIGINT/SIGTERM.
|
|
15
|
+
* Exit codes: 0 ran and restored, 1 the find text is empty or did not match exactly once or the
|
|
16
|
+
* restore hash differed (the file was still restored), 2 usage, 130/143 interrupted and restored.
|
|
17
|
+
* Read references/testing/junk-classes.md "Proof rule" for how to read a candidate/keeper pair.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto"
|
|
21
|
+
import { readFileSync, writeFileSync } from "node:fs"
|
|
22
|
+
import { resolve } from "node:path"
|
|
23
|
+
import process from "node:process"
|
|
24
|
+
import { parseArgs } from "node:util"
|
|
25
|
+
|
|
26
|
+
export type MutationTest = { readonly label: string; readonly command: readonly string[] }
|
|
27
|
+
|
|
28
|
+
export type MutationOutcome = {
|
|
29
|
+
readonly label: string
|
|
30
|
+
readonly command: readonly string[]
|
|
31
|
+
readonly exitCode: number
|
|
32
|
+
readonly verdict: "red" | "green"
|
|
33
|
+
readonly outputTail: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type MutationResult = {
|
|
37
|
+
readonly file: string
|
|
38
|
+
readonly find: string
|
|
39
|
+
readonly replace: string
|
|
40
|
+
readonly originalSha256: string
|
|
41
|
+
readonly restoredSha256: string
|
|
42
|
+
readonly outcomes: readonly MutationOutcome[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class MutationError extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
readonly kind: "FIND_EMPTY" | "FIND_NOT_UNIQUE" | "RESTORE_MISMATCH",
|
|
48
|
+
message: string,
|
|
49
|
+
) {
|
|
50
|
+
super(message)
|
|
51
|
+
this.name = "MutationError"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sha256 = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex")
|
|
56
|
+
|
|
57
|
+
async function runCaptured(command: readonly string[], cwd: string): Promise<{ readonly exitCode: number; readonly outputTail: string }> {
|
|
58
|
+
const child = Bun.spawn([...command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" })
|
|
59
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
60
|
+
new Response(child.stdout).text(),
|
|
61
|
+
new Response(child.stderr).text(),
|
|
62
|
+
child.exited,
|
|
63
|
+
])
|
|
64
|
+
const lines = `${stdout}${stderr}`.trimEnd().split("\n")
|
|
65
|
+
return { exitCode, outputTail: lines.slice(-30).join("\n") }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runMutation(options: {
|
|
69
|
+
readonly cwd: string
|
|
70
|
+
readonly file: string
|
|
71
|
+
readonly find: string
|
|
72
|
+
readonly replace: string
|
|
73
|
+
readonly tests: readonly MutationTest[]
|
|
74
|
+
}): Promise<MutationResult> {
|
|
75
|
+
if (options.find.length === 0) throw new MutationError("FIND_EMPTY", `${options.file}: find text is empty`)
|
|
76
|
+
const full = resolve(options.cwd, options.file)
|
|
77
|
+
const original = readFileSync(full)
|
|
78
|
+
const text = original.toString("utf8")
|
|
79
|
+
const occurrences = text.split(options.find).length - 1
|
|
80
|
+
if (occurrences !== 1) {
|
|
81
|
+
throw new MutationError("FIND_NOT_UNIQUE", `${options.file}: find matched ${occurrences} times (need exactly 1)`)
|
|
82
|
+
}
|
|
83
|
+
const originalSha256 = sha256(original)
|
|
84
|
+
const outcomes: MutationOutcome[] = []
|
|
85
|
+
const restoreOnSignal = (signal: NodeJS.Signals): void => {
|
|
86
|
+
writeFileSync(full, original)
|
|
87
|
+
process.exit(signal === "SIGINT" ? 130 : 143)
|
|
88
|
+
}
|
|
89
|
+
process.on("SIGINT", restoreOnSignal)
|
|
90
|
+
process.on("SIGTERM", restoreOnSignal)
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(full, text.replace(options.find, () => options.replace))
|
|
93
|
+
for (const test of options.tests) {
|
|
94
|
+
const run = await runCaptured(test.command, options.cwd)
|
|
95
|
+
outcomes.push({
|
|
96
|
+
label: test.label,
|
|
97
|
+
command: test.command,
|
|
98
|
+
exitCode: run.exitCode,
|
|
99
|
+
verdict: run.exitCode === 0 ? "green" : "red",
|
|
100
|
+
outputTail: run.outputTail,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
writeFileSync(full, original)
|
|
105
|
+
process.off("SIGINT", restoreOnSignal)
|
|
106
|
+
process.off("SIGTERM", restoreOnSignal)
|
|
107
|
+
}
|
|
108
|
+
const restoredSha256 = sha256(readFileSync(full))
|
|
109
|
+
if (restoredSha256 !== originalSha256) {
|
|
110
|
+
throw new MutationError("RESTORE_MISMATCH", `${options.file} not restored byte for byte`)
|
|
111
|
+
}
|
|
112
|
+
return { file: options.file, find: options.find, replace: options.replace, originalSha256, restoredSha256, outcomes }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseTest(spec: string): MutationTest | undefined {
|
|
116
|
+
const separator = spec.indexOf("=")
|
|
117
|
+
if (separator <= 0) return undefined
|
|
118
|
+
const label = spec.slice(0, separator)
|
|
119
|
+
const command = spec
|
|
120
|
+
.slice(separator + 1)
|
|
121
|
+
.split(" ")
|
|
122
|
+
.filter((word) => word.length > 0)
|
|
123
|
+
return command.length === 0 ? undefined : { label, command }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function usage(): number {
|
|
127
|
+
console.error(
|
|
128
|
+
"usage: mutation-proof.ts --file <path> --find <text> --replace <text> --test '<label>=<argv...>' [--test ...] [--cwd <dir>] [--json]",
|
|
129
|
+
)
|
|
130
|
+
return 2
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function main(argv: readonly string[]): Promise<number> {
|
|
134
|
+
const { values } = parseArgs({
|
|
135
|
+
args: [...argv],
|
|
136
|
+
options: {
|
|
137
|
+
file: { type: "string" },
|
|
138
|
+
find: { type: "string" },
|
|
139
|
+
replace: { type: "string" },
|
|
140
|
+
test: { type: "string", multiple: true },
|
|
141
|
+
cwd: { type: "string", default: process.cwd() },
|
|
142
|
+
json: { type: "boolean", default: false },
|
|
143
|
+
},
|
|
144
|
+
strict: true,
|
|
145
|
+
})
|
|
146
|
+
if (values.file === undefined || values.find === undefined || values.replace === undefined || values.test === undefined) {
|
|
147
|
+
return usage()
|
|
148
|
+
}
|
|
149
|
+
const tests: MutationTest[] = []
|
|
150
|
+
for (const spec of values.test) {
|
|
151
|
+
const parsed = parseTest(spec)
|
|
152
|
+
if (parsed === undefined) return usage()
|
|
153
|
+
tests.push(parsed)
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const result = await runMutation({
|
|
157
|
+
cwd: values.cwd ?? process.cwd(),
|
|
158
|
+
file: values.file,
|
|
159
|
+
find: values.find,
|
|
160
|
+
replace: values.replace,
|
|
161
|
+
tests,
|
|
162
|
+
})
|
|
163
|
+
if (values.json) {
|
|
164
|
+
console.log(JSON.stringify(result, null, 2))
|
|
165
|
+
} else {
|
|
166
|
+
for (const outcome of result.outcomes) console.log(`${outcome.label}: ${outcome.verdict} (exit ${outcome.exitCode})`)
|
|
167
|
+
console.log(`restored ${result.file} (sha256 ${result.restoredSha256.slice(0, 12)})`)
|
|
168
|
+
}
|
|
169
|
+
return 0
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof MutationError) {
|
|
172
|
+
console.error(`mutation-proof: ${error.kind}: ${error.message}`)
|
|
173
|
+
return 1
|
|
174
|
+
}
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (import.meta.main) process.exit(await main(process.argv.slice(2)))
|