Skip to content

Directory context artifacts embed compiled bytecode: the binary guard in readArtifactContent() can never fire, and the walk honours no ignore file #116

Description

@gregoryfoster

Two defects in readArtifactContent() combine so that a directory context artifact silently embeds compiled bytecode and other build output. Read out of the published package, socraticode@1.12.0, dist/services/context-artifacts.js (lines 125–145).

Both are one-line-ish fixes; I'm happy to send a PR for either or both.

1. The directory walk has a hardcoded ignore list and imports no ignore service

if (stat.isDirectory()) {
    // Find all files in the directory (recursively, skip hidden/dot-files)
    const files = await glob("**/*", {
        cwd: resolved,
        nodir: true,
        dot: false,
        ignore: ["**/node_modules/**", "**/.git/**"],
    });

node_modules and .git, and nothing else. Grepping the module for ignore returns exactly these two occurrences and no .gitignore / .socraticodeignore reference anywhere — so there is no code path by which either ignore file can apply to a context artifact. This is not a load-order or staleness effect; I confirmed it behaviourally first (adding __pycache__/ and *.pyc to .socraticodeignore and re-indexing left the artifact's chunk count identical) and then in the source.

That is defensible as a deliberate choice, but it is not what the README's index-scope framing leads a user to expect, and it is the general case that lets the next consumer embed dist/, .pytest_cache/ or coverage data.

2. The binary guard is already written, and it can never fire

The loop immediately below is what turns (1) into embedded mojibake rather than merely extra text files:

for (const file of files) {
    const filePath = path.join(resolved, file);
    try {
        const content = await fsp.readFile(filePath, "utf-8");
        parts.push(`# ── ${file} ──\n${content}`);
    }
    catch {
        // skip unreadable files (binary, permissions, etc.)
        logger.debug(`Artifact: skipping unreadable file ${file}`);
    }
}

The comment states the intent exactly — skip binary. It never happens, because fsp.readFile(path, "utf-8") does not throw on binary input; it returns a string with U+FFFD replacement characters. Verified against a .pyc fixture on Node:

readFile utf-8 on binary: RETURNED (no throw), len 8, hasFFFD true

So every .pyc takes the success branch and is embedded, mojibake and all. Nothing is unreadable, so nothing is skipped, so nothing is logged — the logger.debug line is dead code.

Measured consequence

A project declaring {"name": "alembic-migrations", "path": "./alembic/versions"} — a natural, documented-shape artifact. Every test run and every alembic invocation drops __pycache__/ into that directory:

  • 70 .pyc files embedded, 352K of bytecode against 320K of source
  • 32 of the artifact's 86 chunks were compiled bytecode
  • a codebase_context_search for "what is the current alembic migration head revision" returned decompiled bytecode as its top hit
chunks
baseline 86
after adding __pycache__/ + *.pyc to .socraticodeignore, re-indexed 86
after rm -rf alembic/versions/__pycache__, re-indexed 54

Top-hit score for the same query went 0.5417 → 0.6111 once the bytecode was gone.

Why this is worth fixing rather than documenting

It fails silently and upward. Nothing errors, the chunk count rises, and codebase_status reports more artifact content indexed than before. Every available signal says the artifact got healthier while search quality got worse — so there is no observation a user can make that would prompt them to look. The only reason I found it was chasing a bad search result back to its chunk.

Suggested fixes

  1. Make the binary guard actually detect binary. This alone fixes the bytecode case and preserves the existing intent. Read as a Buffer and either sniff for a NUL byte in the first few KB, or decode with a fatal decoder:

    const buf = await fsp.readFile(filePath);
    if (buf.includes(0)) { logger.debug(`Artifact: skipping binary file ${file}`); continue; }
    const content = buf.toString("utf-8");

    (new TextDecoder("utf-8", { fatal: true }).decode(buf) inside the existing try would also work and would make the catch live, at the cost of rejecting a few legitimately-latin1 text files.)

  2. Wire in the ignore chain the code indexer already uses, so a directory artifact honours the built-in defaults + .gitignore + .socraticodeignore. This is the general fix and the one that stops the next unanticipated build directory.

A cheap partial alternative to (2), if wiring the ignore service into artifacts is unwanted for scope reasons: extend the hardcoded list with the common build-output names (**/__pycache__/**, **/dist/**, **/build/**, **/*.pyc). Less principled, but it covers the case above.

Worth noting for (1) alone: the artifact walk already passes dot: false, so dot-directories like .pytest_cache/ are excluded — __pycache__ is the notable build directory that is not hidden and therefore is walked.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions