Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/curly-moons-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"etymd": minor
---

Three fixes to the generated gates and the onboarding flow:

- **zsh is out of the shellcheck scan (pack v10).** The generated pre-push discovered shell
scripts by a shebang pattern that included zsh, but shellcheck cannot parse zsh — SC1071 is a
parser-level error no inline directive silences — so a repo whose executable surface is zsh
could never push. zsh shebangs are now excluded from the checked set and the hook prints the
exclusion (count + reason) at run time instead of going quiet about coverage. Pack v9 is skipped so no two
template meanings ever share a version.
- **`~/` home paths are no longer repo file references.** Prose like "global rules in
`~/.claude/CLAUDE.md` apply on top" made the audit demand a repo-root CLAUDE.md that was
never meant to exist — the sentence points at the reader's machine. Home-path mentions of
well-known docs are now skipped and disclosed, like absolute tokens; one ordinary mention
still makes the doc a live claim.
- **`init` no longer scaffolds AGENTS.md unasked.** `init -y` in a repo without a contract used
to write template prose nobody reviewed (and baseline it, making later deletion read as
drift). The scaffold is now opt-in via `--with-agents`; interactive runs still ask first.
2 changes: 1 addition & 1 deletion .githooks/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ if [ -x "$LOCAL" ]; then
fi

exit 0
# etymd:generated pack-v8 fb5f852715904344
# etymd:generated pack-v10 fb5f852715904344
2 changes: 1 addition & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ if [ -x "$GATE" ]; then
fi

exit 0
# etymd:generated pack-v8 44d63e264851f3e0
# etymd:generated pack-v10 44d63e264851f3e0
15 changes: 12 additions & 3 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,20 @@ npm run typecheck || exit 1
echo "› npm run test"
npm run test || exit 1
# Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
# script added later is covered without regenerating this hook.
# script added later is covered without regenerating this hook. zsh is NOT in the checked set:
# shellcheck cannot parse it (SC1071 is a parser-level error no inline directive can silence),
# so checking it would fail every push on the parser, not on the script. Excluded — and said so
# at run time below, because a coverage hole that is silent is indistinguishable from coverage.
if command -v shellcheck >/dev/null 2>&1; then
scripts=$(git ls-files -z \
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da|z)?sh( |$)" && echo "{}"' \
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da)?sh( |$)" && echo "{}"' \
| sort)
zsh_scripts=$(git ls-files -z \
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ]zsh( |$)" && echo "{}"' \
| sort)
if [ -n "$zsh_scripts" ]; then
echo "› shellcheck: $(printf '%s\n' "$zsh_scripts" | wc -l | tr -d ' ') zsh script(s) excluded — shellcheck cannot parse zsh (SC1071); not checked, not failed"
fi
if [ -n "$scripts" ]; then
echo "› shellcheck ($(printf '%s\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
printf '%s\n' "$scripts" | xargs shellcheck -S warning || {
Expand Down Expand Up @@ -54,4 +63,4 @@ if [ -x "$GATE" ]; then
fi

exit 0
# etymd:generated pack-v8 3138bee89153be59
# etymd:generated pack-v10 26d6adf016d8595a
2 changes: 1 addition & 1 deletion scripts/artifact-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ fi

"$GATE" screen --dir "$WORK" || exit 1
exit 0
# etymd:generated pack-v8 7e8e0e079bf580c3
# etymd:generated pack-v10 7e8e0e079bf580c3
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ program

program
.command("init")
.description("Onboard the truth guard: approve the baseline; scaffold AGENTS.md only if missing")
.description("Onboard the truth guard: approve the baseline; scaffold AGENTS.md only if asked")
.option("-y, --yes", "accept defaults without prompting (never overwrites)")
.option("--with-agents", "also scaffold a minimal AGENTS.md where none exists (off by default)")
.action((opts, cmd) =>
action(async () => {
const { run } = await import("./commands/init.js")
await run({ cwd: resolveCwd(cmd), yes: opts.yes })
await run({ cwd: resolveCwd(cmd), yes: opts.yes, withAgents: opts.withAgents })
}),
)

Expand Down
21 changes: 13 additions & 8 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { glyph, theme } from "../ui/theme.js"
export interface InitOptions {
cwd: string
yes?: boolean
/** Scaffold the minimal AGENTS.md where none exists — opt-in, never a `-y` default. */
withAgents?: boolean
}

function bail(): never {
Expand Down Expand Up @@ -74,14 +76,17 @@ export async function run(opts: InitOptions): Promise<void> {
const hasContract = facts.artifacts.some((a) => a.id === "agents" && a.exists)
let scaffoldAgents = false
if (!hasContract) {
scaffoldAgents = opts.yes
? true
: (guard(
await confirm({
message: "No AGENTS.md found — scaffold a minimal one from the reckoning?",
initialValue: true,
}),
) as boolean)
// Opt-in only: the flag, or an interactive answer. `-y` alone must never land template prose
// nobody reviewed — a mechanical baseline-only rollout would seed unfilled contracts (and
// baselines that then defend them) across many repos at once.
if (opts.withAgents) scaffoldAgents = true
else if (!opts.yes)
scaffoldAgents = guard(
await confirm({
message: "No AGENTS.md found — scaffold a minimal one from the reckoning?",
initialValue: true,
}),
) as boolean
}

let gates = false
Expand Down
35 changes: 33 additions & 2 deletions src/lenses/instruction-truth/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,37 @@ export const KNOWN_DOC_REFS = [
"GEMINI.md",
]

export function extractDocRefs(text: string): string[] {
return KNOWN_DOC_REFS.filter((name) => text.includes(name))
// The characters a path token is built from — walking back over these from a mention reaches the
// token's head, which is where a `~` marking a HOME path would sit.
const PATH_TOKEN_CHARS = /[A-Za-z0-9_.$~/-]/

export interface DocRefs {
/** Well-known docs the file points at as files of THIS repo. */
refs: string[]
/** Mentions embedded in `~/`-home paths — outside the repo, unverifiable from it. */
tildeSkipped: number
}

/**
* A bare substring match is not enough: `~/.claude/CLAUDE.md` mentions CLAUDE.md but points at
* the reader's machine, never at the repo — treating it as a repo ref accused a true sentence of
* lying (the home file existed; the repo never had one). A home-path occurrence is skipped and
* counted like the absolute tokens below; one ordinary occurrence still makes the doc a claim.
*/
export function extractDocRefs(text: string): DocRefs {
const refs: string[] = []
let tildeSkipped = 0
for (const name of KNOWN_DOC_REFS) {
let claimed = false
let at = text.indexOf(name)
while (at !== -1) {
let head = at
while (head > 0 && PATH_TOKEN_CHARS.test(text[head - 1] as string)) head -= 1
if (text[head] === "~") tildeSkipped += 1
else claimed = true
at = text.indexOf(name, at + name.length)
}
if (claimed) refs.push(name)
}
return { refs, tildeSkipped }
}
12 changes: 10 additions & 2 deletions src/lenses/instruction-truth/lens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const instructionTruthLens: Lens = {
(await pathExists(path.join(root, "package.json"))) || facts.packages.length > 0

let totalFilteredSkipped = 0
let totalTildeSkipped = 0
let binaryResolved = 0
let unverifiableCommands = 0
let gitignoredSkipped = 0
Expand Down Expand Up @@ -272,7 +273,9 @@ export const instructionTruthLens: Lens = {
}

// Cross-references to well-known docs must resolve.
for (const ref of extractDocRefs(file.text)) {
const { refs: docRefs, tildeSkipped } = extractDocRefs(file.text)
totalTildeSkipped += tildeSkipped
for (const ref of docRefs) {
if (await pathExists(path.join(root, ref))) continue
findings.push(
finding({
Expand Down Expand Up @@ -391,6 +394,11 @@ export const instructionTruthLens: Lens = {
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`,
)
}
if (totalTildeSkipped) {
disclosures.push(
`${totalTildeSkipped} well-known doc mention(s) sit inside \`~/\` home paths (e.g. \`~/.claude/CLAUDE.md\`) — machine-global files, not this repo's; skipped, not flagged.`,
)
}
if (stateDocs.length) {
disclosures.push(
`Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${
Expand Down Expand Up @@ -424,7 +432,7 @@ export const instructionTruthLens: Lens = {
)
}
disclosures.push(
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; framework-pattern staleness not checked.`,
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`,
)

return {
Expand Down
13 changes: 11 additions & 2 deletions src/pack/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,11 +371,20 @@ exit 0
function shellcheckStep(): string {
return `
# Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
# script added later is covered without regenerating this hook.
# script added later is covered without regenerating this hook. zsh is NOT in the checked set:
# shellcheck cannot parse it (SC1071 is a parser-level error no inline directive can silence),
# so checking it would fail every push on the parser, not on the script. Excluded — and said so
# at run time below, because a coverage hole that is silent is indistinguishable from coverage.
if command -v shellcheck >/dev/null 2>&1; then
scripts=$(git ls-files -z \\
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da|z)?sh( |$)" && echo "{}"' \\
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da)?sh( |$)" && echo "{}"' \\
| sort)
zsh_scripts=$(git ls-files -z \\
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ]zsh( |$)" && echo "{}"' \\
| sort)
if [ -n "$zsh_scripts" ]; then
echo "› shellcheck: $(printf '%s\\n' "$zsh_scripts" | wc -l | tr -d ' ') zsh script(s) excluded — shellcheck cannot parse zsh (SC1071); not checked, not failed"
fi
if [ -n "$scripts" ]; then
echo "› shellcheck ($(printf '%s\\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
printf '%s\\n' "$scripts" | xargs shellcheck -S warning || {
Expand Down
5 changes: 4 additions & 1 deletion src/pack/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
* The knowledge-pack version — bumped whenever templates, the rubric, or the encoded rules
* change meaning. Stamped into facts, baselines, and generated artifacts so drift against the
* pack is computable and `harvest` has something to diff.
*
* v9 was claimed by a change that never shipped; the number is skipped so no two template
* meanings ever share a version.
*/
export const PACK_VERSION = "8"
export const PACK_VERSION = "10"
4 changes: 3 additions & 1 deletion test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ describe("init approves the repo it leaves behind, not the one it found", () =>
})

it("baselines the scaffold it just wrote, so a later deletion still reads as drift", async () => {
await runInit({ cwd: dir, yes: true })
// The scaffold is opt-in — the flag is what makes init write a contract here, and the
// baseline must still reflect the repo init LEAVES BEHIND.
await runInit({ cwd: dir, yes: true, withAgents: true })

const baseline = await readBaseline(dir)
const contract = baseline?.facts.artifacts.find((a) => a.id === "agents")
Expand Down
59 changes: 58 additions & 1 deletion test/gates.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process"
import { execFile, execSync } from "node:child_process"
import { existsSync, promises as fs } from "node:fs"
import os from "node:os"
import path from "node:path"
Expand Down Expand Up @@ -76,3 +76,60 @@ describe.skipIf(!existsSync(CLI))("etymd gates — the written tier is derived a
expect(out).toContain(path.join(".etymd", "config.json"))
})
})

// Behavioral runs below prove blocking/clean against the REAL checker — only where it exists.
let hasShellcheck = false
try {
execSync("command -v shellcheck", { stdio: "ignore" })
hasShellcheck = true
} catch {
/* not on PATH — the hook's own absent-checker branch covers that case */
}

describe.skipIf(!existsSync(CLI))("etymd gates — zsh is outside shellcheck's reach", () => {
it("the shebang scan hands only sh/bash/dash to shellcheck, and the hook says why", async () => {
await write("package.json", JSON.stringify({ name: "zshy", private: true }, null, 2) + "\n")
await write("AGENTS.md", "# AGENTS.md\n")
await write("tool/run.zsh", "#!/bin/zsh\necho hi\n")
await pExecFile("git", ["add", "."], { cwd: dir })

await gates()
const hook = await prePush()
// The checked set: sh, bash, dash — zsh dropped from the character class.
expect(hook).toContain("(ba|da)?sh")
expect(hook).not.toContain("(ba|da|z)?sh")
// The exclusion is a disclosed skip, not silent absence of coverage.
expect(hook).toContain("SC1071")
expect(hook).toContain("zsh script(s) excluded")
})

it.skipIf(!hasShellcheck)(
"PINNED: a repo whose surface is zsh pushes clean through the fresh hook",
async () => {
await write("package.json", JSON.stringify({ name: "zshy", private: true }, null, 2) + "\n")
await write("AGENTS.md", "# AGENTS.md\n")
await write("tool/run.zsh", "#!/bin/zsh\necho hi\n")
await pExecFile("git", ["add", "."], { cwd: dir })

await gates()
// Running the generated hook directly is what a push executes. Before the fix this died
// inside shellcheck on SC1071 — a parser error, not a finding.
const { stdout } = await pExecFile("sh", [path.join(dir, ".githooks", "pre-push")], {
cwd: dir,
})
expect(stdout).toContain("zsh script(s) excluded")
},
)

it.skipIf(!hasShellcheck)("a bash script with a real warning still blocks the push", async () => {
await write("package.json", JSON.stringify({ name: "bashy", private: true }, null, 2) + "\n")
await write("AGENTS.md", "# AGENTS.md\n")
await write("tool/do.sh", "#!/bin/bash\nnever_used=1\necho ok\n")
await pExecFile("git", ["add", "."], { cwd: dir })

await gates()
await expect(
pExecFile("sh", [path.join(dir, ".githooks", "pre-push")], { cwd: dir }),
).rejects.toThrow()
})
})
57 changes: 57 additions & 0 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { execFile } from "node:child_process"
import { existsSync, promises as fs } from "node:fs"
import os from "node:os"
import path from "node:path"
import { promisify } from "node:util"

import { afterEach, beforeEach, describe, expect, it } from "vitest"

const pExecFile = promisify(execFile)

// The built CLI (skipped when dist/ is absent; `npm ci` builds it via `prepare`, so CI has it).
const CLI = path.resolve(import.meta.dirname, "..", "dist", "cli.js")

let dir: string

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "etymd-init-"))
await pExecFile("git", ["init", "-q"], { cwd: dir })
})

afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true })
})

async function write(rel: string, contents: string) {
const abs = path.join(dir, rel)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, contents, "utf8")
}

async function init(...args: string[]): Promise<string> {
const { stdout } = await pExecFile("node", [CLI, "init", ...args], { cwd: dir })
return stdout
}

describe.skipIf(!existsSync(CLI))("etymd init — the AGENTS.md scaffold is opt-in", () => {
it("PINNED: -y in a repo without AGENTS.md creates only .etymd, no template contract", async () => {
// Hooks pre-exist so the gates half of init has nothing to add — this isolates the scaffold.
await write("package.json", JSON.stringify({ name: "demo", private: true }, null, 2) + "\n")
await write(".githooks/pre-commit", "#!/bin/sh\nexit 0\n")

await init("-y")
expect(existsSync(path.join(dir, ".etymd", "baseline.json"))).toBe(true)
// The defect: a mechanical baseline-only rollout used to land unfilled contract prose
// nobody reviewed — and the baseline then defended it.
expect(existsSync(path.join(dir, "AGENTS.md"))).toBe(false)
})

it("-y --with-agents scaffolds the minimal contract where none exists", async () => {
await write("package.json", JSON.stringify({ name: "demo", private: true }, null, 2) + "\n")
await write(".githooks/pre-commit", "#!/bin/sh\nexit 0\n")

await init("-y", "--with-agents")
expect(existsSync(path.join(dir, ".etymd", "baseline.json"))).toBe(true)
expect(existsSync(path.join(dir, "AGENTS.md"))).toBe(true)
})
})
Loading
Loading