Skip to content

feat(opensmith): add parser front-end AST roundtrip scaffold - #5

Open
ludoplex wants to merge 7 commits into
feat/opensmith-parity-pr1-corpus-harnessfrom
feat/opensmith-parity-pr2-parser-frontend
Open

feat(opensmith): add parser front-end AST roundtrip scaffold#5
ludoplex wants to merge 7 commits into
feat/opensmith-parity-pr1-corpus-harnessfrom
feat/opensmith-parity-pr2-parser-frontend

Conversation

@ludoplex

@ludoplex ludoplex commented Mar 4, 2026

Copy link
Copy Markdown
Owner

PR2 parser front-end scaffold.

Summary by Sourcery

Introduce an OpenSmith parser/AST front-end tool and hook it into the build and parity workflows for corpus-based roundtrip validation.

New Features:

  • Add the opensmithgen C-based front-end tool to parse OpenSmith templates and emit AST JSON, reconstructed templates, roundtrip checks, and stats.
  • Add a Python-based opensmith_frontend_check harness to run parser/AST roundtrip checks over the extracted OpenSmith corpus fixtures.

Enhancements:

  • Extend the Makefile toolchain to build opensmithgen, track .cst/.csp/.csmap fixtures, and provide an opensmith-frontend-check target.
  • Update project and OpenSmith parity documentation to describe the new front-end tool, formats, and usage commands.

Documentation:

  • Document the OpenSmith front-end parser scaffold, its supported formats, and how to run opensmith-frontend-check in the main and OpenSmith-specific READMEs and parity guide.

Copilot AI review requested due to automatic review settings March 4, 2026 19:47
@sourcery-ai

sourcery-ai Bot commented Mar 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a new Ring 0 tool opensmithgen that parses OpenSmith templates into an AST, supports roundtrip validation, and wires it into the build and documentation via a make opensmith-frontend-check corpus harness script.

Class diagram for opensmithgen parser/AST front-end structures

classDiagram
    class node_kind_t {
        <<enum>>
        NODE_LITERAL
        NODE_DIRECTIVE
        NODE_EXPRESSION
        NODE_STATEMENT
        NODE_COMMENT
    }

    class mode_t {
        <<enum>>
        MODE_AST
        MODE_ROUNDTRIP
        MODE_CHECK_ROUNDTRIP
        MODE_STATS
    }

    class attr_t {
        char* key
        char* value
    }

    class ast_node_t {
        node_kind_t kind
        size_t start
        size_t end
        int line
        int col
        char* raw
        char* inner
        char* body
        char* directive_name
        attr_t* attrs
        size_t attr_count
        size_t attr_cap
    }

    class ast_doc_t {
        ast_node_t* nodes
        size_t count
        size_t cap
    }

    class parse_error_t {
        int line
        int col
        char msg[160]
    }

    class opensmithgen_main {
        +int main(int argc, char** argv)
        +void usage(const char* argv0)
        +int read_file(const char* path, char** out, size_t* out_len)
        +int parse_template(const char* buf, size_t len, ast_doc_t* doc, parse_error_t* err)
        +void emit_ast_json(FILE* out, const char* path, const ast_doc_t* doc)
        +int reconstruct(const ast_doc_t* doc, char** out, size_t* out_len)
        +int check_roundtrip(const char* orig, size_t orig_len, const ast_doc_t* doc)
        +void emit_stats(FILE* out, const ast_doc_t* doc)
        +void free_doc(ast_doc_t* doc)
        +void free_node(ast_node_t* n)
    }

    class helpers {
        +void* xmalloc(size_t n)
        +void* xrealloc(void* ptr, size_t n)
        +char* xstrndup0(const char* s, size_t n)
        +char* dup_trimmed(const char* s)
        +int is_ident_char(int c)
        +const char* node_kind_str(node_kind_t kind)
        +const char* directive_role(const char* name)
        +int add_attr(ast_node_t* node, char* key, char* value)
        +int add_node(ast_doc_t* doc, ast_node_t* node)
        +void advance_pos(const char* buf, size_t start, size_t end, int* line, int* col)
        +size_t find_token(const char* buf, size_t start, size_t len, const char* tok)
        +void parse_directive_attrs(ast_node_t* node)
        +void classify_node(ast_node_t* node)
        +void json_escape(FILE* out, const char* s)
    }

    ast_doc_t "1" o-- "*" ast_node_t : owns
    ast_node_t "1" o-- "*" attr_t : has

    opensmithgen_main ..> ast_doc_t : uses
    opensmithgen_main ..> ast_node_t : uses
    opensmithgen_main ..> parse_error_t : uses
    opensmithgen_main ..> node_kind_t : uses
    opensmithgen_main ..> mode_t : uses

    helpers ..> ast_doc_t : manipulates
    helpers ..> ast_node_t : manipulates
    helpers ..> attr_t : manipulates
    helpers ..> node_kind_t : classifies
Loading

Flow diagram for opensmith_frontend_check fixture processing

flowchart TD
    Start([Start])
    ParseArgs["parse_args"]
    ResolvePaths["resolve tool, inventory, corpus_dir"]
    ReadLock["read_lock"]
    CollectFixtures["collect_fixtures"]
    CheckMissing["check for missing fixture files"]
    LoopFixtures{{"for each fixture"}}
    RunRoundtrip["run --check-roundtrip"]
    RoundtripFail{{"roundtrip returncode != 0"}}
    LogRTFail["log roundtrip FAIL (<=20) and stderr"]
    RunAst["run --ast"]
    AstExitFail{{"ast returncode != 0"}}
    LogAstFail["log ast FAIL (<=20) and stderr"]
    ValidateJson["json.loads(ast stdout); ensure dict with nodes"]
    JsonFail{{"JSON invalid"}}
    LogJsonFail["log ast json FAIL (<=20)"]
    NextFixture["next fixture"]
    Summary["print fixtures checked, roundtrip failures, ast failures"]
    AnyFail{{"roundtrip_fail or ast_fail"}}
    Exit3[["exit 3"]]
    Exit0[["exit 0"]]
    Exit2[["exit 2"]]
    Exit1[["exit 1"]]

    Start --> ParseArgs --> ResolvePaths
    ResolvePaths --> ReadLock
    ReadLock --> CollectFixtures

    CollectFixtures -->|no fixtures| Exit1
    CollectFixtures -->|fixtures found| CheckMissing

    CheckMissing -->|missing files| Exit2
    CheckMissing -->|all present| LoopFixtures

    LoopFixtures -->|fixtures remaining| RunRoundtrip
    LoopFixtures -->|none| Summary

    RunRoundtrip --> RoundtripFail
    RoundtripFail -->|yes| LogRTFail --> NextFixture --> LoopFixtures
    RoundtripFail -->|no| RunAst

    RunAst --> AstExitFail
    AstExitFail -->|yes| LogAstFail --> NextFixture --> LoopFixtures
    AstExitFail -->|no| ValidateJson

    ValidateJson --> JsonFail
    JsonFail -->|yes| LogJsonFail --> NextFixture --> LoopFixtures
    JsonFail -->|no| NextFixture --> LoopFixtures

    Summary --> AnyFail
    AnyFail -->|yes| Exit3
    AnyFail -->|no| Exit0
Loading

File-Level Changes

Change Details Files
Wire new OpenSmith frontend parser tool into Makefile and build pipeline, and expose a corpus-level frontend check target.
  • Track .cst/.csp/.csmap files as first-class spec formats and report their counts in the formats helper target.
  • Add opensmithgen to Ring 0 tools, including a compile rule, and ensure it is built by make tools.
  • Introduce a new phony target opensmith-frontend-check that runs a Python harness over the extracted OpenSmith corpus using opensmithgen.
  • Extend help output and top-level README to document the new frontend check command and its usage.
Makefile
README.md
Document that OpenSmith PR scope now includes a parser/AST frontend scaffold rather than only parity harness plumbing.
  • Update OpenSmith parity documentation to describe PR1/PR2 scope, explicitly calling out the parser/AST frontend scaffold and the new opensmith-frontend-check command.
  • Clarify how to run the frontend roundtrip checks over .cst/.csp/.csmap fixtures in the testing docs.
  • Update SPEC_TYPES to associate .cst/.csp/.csmap with opensmithgen and describe their status as frontend parser + AST scaffold rather than future generators.
docs/OPENSMITH_PARITY.md
specs/testing/opensmith/README.md
SPEC_TYPES.md
Add opensmithgen Ring 0 C tool implementing a lightweight OpenSmith template parser, AST model, JSON output, roundtrip reconstruction, and validation modes.
  • Define node kinds, position tracking, and attribute structures for the AST, including directive metadata with normalized roles.
  • Implement a streaming parser that splits literal vs tag segments (<% ... %>), classifies tags as directives, expressions, statements, or comments, and captures raw/inner/body text with offsets and line/column numbers.
  • Implement directive attribute parsing with basic key/value handling and tolerant quoting rules, plus helper classification for directive roles (template/property/include/etc.).
  • Provide multiple driver modes: AST-as-JSON emission, raw template roundtrip reconstruction, roundtrip consistency checking with detailed mismatch reporting, and node stats reporting.
  • Include robust file I/O, memory management helpers, JSON escaping, and error reporting for parse failures.
tools/opensmithgen/opensmithgen.c
Introduce a Python harness to drive opensmithgen over the OpenSmith corpus lock and validate parser/AST roundtrip behavior.
  • Load the OpenSmith corpus lock JSON and materialize safe relative paths into the extracted corpus for nested and outer entries, with extension-based filtering over .cst/.csp/.csmap.
  • Support optional glob pattern and limit arguments for selecting subsets of fixtures.
  • Run opensmithgen in --check-roundtrip mode to ensure textual stability and in --ast mode to validate JSON shape per fixture, counting and reporting failures.
  • Validate that all selected fixtures exist on disk and summarize counts of checked fixtures and failure types for CI/automation use.
scripts/opensmith_frontend_check.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 1 other issue, and left some high level feedback:

Security issues:

  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)

General comments:

  • In opensmithgen.usage the usage string and examples still mention only <input.cst>, but the tool is used for .cst/.csp/.csmap in the frontend check; consider updating the help text to advertise all supported extensions so it matches how the tool is invoked elsewhere.
  • In opensmith_frontend_check.py, returning exit code 1 when no fixtures are selected (print("no fixtures selected")) may be surprising in CI when using a restrictive --pattern or --ext filter; consider returning 0 in that case or adding a flag to treat an empty selection as success.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `opensmithgen.usage` the usage string and examples still mention only `<input.cst>`, but the tool is used for `.cst/.csp/.csmap` in the frontend check; consider updating the help text to advertise all supported extensions so it matches how the tool is invoked elsewhere.
- In `opensmith_frontend_check.py`, returning exit code 1 when no fixtures are selected (`print("no fixtures selected")`) may be surprising in CI when using a restrictive `--pattern` or `--ext` filter; consider returning 0 in that case or adding a flag to treat an empty selection as success.

## Individual Comments

### Comment 1
<location path="tools/opensmithgen/opensmithgen.c" line_range="356-357" />
<code_context>
+    int col = 1;
+
+    while (i < len) {
+        size_t tag_start = find_token(buf, i, len, "<%");
+        if (tag_start == (size_t)-1) {
+            if (tag_start != i) {
+                ast_node_t lit;
</code_context>
<issue_to_address>
**nitpick:** The `if (tag_start != i)` check in the EOF case is misleading and always true.

In the `tag_start == (size_t)-1` branch, `tag_start` is always `(size_t)-1` while `i` is a valid offset, so `if (tag_start != i)` is effectively always true. Since the behavior (emitting the trailing literal) is correct but the condition is misleading, consider replacing it with something that reflects the real intent (e.g. `if (i < len)`) or removing the `if` and adding a brief comment explaining the control flow.
</issue_to_address>

### Comment 2
<location path="scripts/opensmith_frontend_check.py" line_range="69" />
<code_context>
    return subprocess.run(cmd, text=True, capture_output=True)
</code_context>
<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +356 to +357
size_t tag_start = find_token(buf, i, len, "<%");
if (tag_start == (size_t)-1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: The if (tag_start != i) check in the EOF case is misleading and always true.

In the tag_start == (size_t)-1 branch, tag_start is always (size_t)-1 while i is a valid offset, so if (tag_start != i) is effectively always true. Since the behavior (emitting the trailing literal) is correct but the condition is misleading, consider replacing it with something that reflects the real intent (e.g. if (i < len)) or removing the if and adding a brief comment explaining the control flow.

Comment thread scripts/opensmith_frontend_check.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8028d2400

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +324 to +325
if (tlen >= 2 && trimmed[0] == '-' && trimmed[1] == '-') {
node->kind = NODE_COMMENT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Classify comments only for <%-- ... --%> tags

classify_node() currently treats any trimmed block beginning with -- as a comment, so valid statement blocks like <% --i; %> are misclassified as NODE_COMMENT instead of NODE_STATEMENT. This produces incorrect AST node kinds for legal templates and can corrupt downstream parity metrics or codegen behavior that depends on node type.

Useful? React with 👍 / 👎.


if (tlen >= 2 && trimmed[0] == '-' && trimmed[1] == '-') {
node->kind = NODE_COMMENT;
node->body = dup_trimmed(trimmed + 2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip closing comment marker from parsed comment body

The comment-body extraction only removes the leading -- (dup_trimmed(trimmed + 2)), so canonical comments like <%-- hello --%> produce a body of hello -- with the trailing delimiter fragment still attached. This makes comment payloads consistently inaccurate for standard OpenSmith comment syntax.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Ring 0 OpenSmith parser/AST “front-end” scaffold (opensmithgen) plus a corpus-wide checker, integrating it into the existing OpenSmith corpus/parity workflow.

Changes:

  • Introduce opensmithgen C tool to parse OpenSmith-style templates into a simple AST, emit JSON, and verify exact roundtrip stability.
  • Add opensmith-frontend-check Makefile target + Python runner to execute roundtrip/AST checks across the extracted corpus fixtures.
  • Update documentation and spec-type inventory to reflect the new front-end scaffold.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tools/opensmithgen/opensmithgen.c New Ring 0 CLI tool: parse tags/literals, emit AST JSON, reconstruct original text, and roundtrip-check.
scripts/opensmith_frontend_check.py New corpus runner that invokes opensmithgen over lock-selected fixtures and validates JSON output.
Makefile Builds opensmithgen, adds opensmith-frontend-check target, and surfaces .cst/.csp/.csmap in make formats.
specs/testing/opensmith/README.md Documents the new front-end check step in the OpenSmith workflow.
docs/OPENSMITH_PARITY.md Expands scope/commands to include front-end roundtrip checks.
SPEC_TYPES.md Reassigns .cst/.csp/.csmap to opensmithgen and updates their status text.
README.md Lists the new make opensmith-frontend-check command.

Comment on lines +325 to +326
node->kind = NODE_COMMENT;
node->body = dup_trimmed(trimmed + 2);

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment nodes parsed from <%-- ... --%> will keep the trailing -- in body (e.g., -- comment -- becomes comment --). If body is intended to represent the comment contents, strip the closing -- (after trimming) when classifying comments so the AST JSON doesn’t include delimiter artifacts.

Suggested change
node->kind = NODE_COMMENT;
node->body = dup_trimmed(trimmed + 2);
char *comment = dup_trimmed(trimmed + 2);
size_t clen = strlen(comment);
if (clen >= 2 && comment[clen - 1] == '-' && comment[clen - 2] == '-') {
size_t newlen = clen - 2;
while (newlen > 0 && isspace((unsigned char)comment[newlen - 1])) {
newlen--;
}
comment[newlen] = '\0';
}
node->kind = NODE_COMMENT;
node->body = comment;

Copilot uses AI. Check for mistakes.
Comment thread scripts/opensmith_frontend_check.py Outdated
Comment on lines +22 to +26
def safe_relpath(parts: Iterable[str]) -> PurePosixPath:
p = PurePosixPath(*parts)
if p.is_absolute() or ".." in p.parts:
raise ValueError(f"unsafe relative path: {p}")
return p

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safe_relpath() uses PurePosixPath and only rejects absolute paths / .. segments. On Windows, backslashes in a lock entry (e.g. ..\\secret) won’t be split into path parts by PurePosixPath but will still be interpreted as separators by the OS when you later call Path.joinpath(*rel.parts), allowing traversal outside --corpus-dir. Consider explicitly rejecting \\ (and drive-letter patterns like C:) in inputs, or validating with PureWindowsPath in addition to PurePosixPath before joining.

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +370
if (tag_start != i) {
ast_node_t lit;
memset(&lit, 0, sizeof(lit));
lit.kind = NODE_LITERAL;
lit.start = i;
lit.end = len;
lit.line = line;
lit.col = col;
lit.raw = xstrndup0(buf + i, len - i);
lit.body = xstrndup0(lit.raw, strlen(lit.raw));
add_node(doc, &lit);
advance_pos(buf, i, len, &line, &col);
}

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is always true when tag_start == (size_t)-1 (since i can never be (size_t)-1), so it doesn’t guard anything and makes the control flow harder to follow. Consider removing the if (tag_start != i) check and unconditionally emitting the final literal segment when no more tags are found.

Suggested change
if (tag_start != i) {
ast_node_t lit;
memset(&lit, 0, sizeof(lit));
lit.kind = NODE_LITERAL;
lit.start = i;
lit.end = len;
lit.line = line;
lit.col = col;
lit.raw = xstrndup0(buf + i, len - i);
lit.body = xstrndup0(lit.raw, strlen(lit.raw));
add_node(doc, &lit);
advance_pos(buf, i, len, &line, &col);
}
ast_node_t lit;
memset(&lit, 0, sizeof(lit));
lit.kind = NODE_LITERAL;
lit.start = i;
lit.end = len;
lit.line = line;
lit.col = col;
lit.raw = xstrndup0(buf + i, len - i);
lit.body = xstrndup0(lit.raw, strlen(lit.raw));
add_node(doc, &lit);
advance_pos(buf, i, len, &line, &col);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not branchless as well?

Comment on lines +559 to +567
for (i = 0; i < doc->count; i++) {
const ast_node_t *n = &doc->nodes[i];
size_t raw_len = n->raw ? strlen(n->raw) : 0;
if (raw_len > ((size_t)-1) - total - 1) {
fprintf(stderr, "error: output too large\n");
return -1;
}
total += raw_len;
}

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reconstruct() recomputes each node’s raw length using strlen(n->raw), which (a) makes reconstruction O(total_bytes) just for length scans, and (b) truncates if a fixture contains embedded NUL bytes even though the parser tracks byte offsets via start/end. Prefer using the already-known length (n->end - n->start) or storing an explicit raw length alongside raw so roundtrips remain byte-accurate and avoid extra scans.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not branchless as well?

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New security issues found

Comment thread scripts/build_opensmithgen_ape.py Outdated
@ludoplex

ludoplex commented Mar 5, 2026

Copy link
Copy Markdown
Owner Author

totally remove python, its no value to this project @copilot

Copilot AI commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

@ludoplex I've opened a new pull request, #7, to work on those changes. Once the pull request is ready, I'll request review from you.

* Initial plan

* refactor: remove all Python scripts, replace with POSIX sh equivalents

Co-authored-by: ludoplex <5705162+ludoplex@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ludoplex <5705162+ludoplex@users.noreply.github.com>
@ludoplex

ludoplex commented Mar 5, 2026

Copy link
Copy Markdown
Owner Author

New security issues found

@sourcery-ai

like what?

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.

Comments suppressed due to low confidence (1)

docs/OPENSMITH_PARITY.md:61

  • This section claims the lock file guarantees fixture selection stability and corpus integrity, but the current opensmith_corpus.sh extract/opensmith_parity.sh paths do not use the lock's entry list or hashes for validation/selection (they rescan the filesystem). Either adjust the scripts to honor the lock file or soften/clarify this documentation claim.
`specs/testing/opensmith/corpus.lock.json` captures:

- source archive hash
- nested sample archive scope
- file extension filters
- deterministic entry list with size + sha256

The lock file is used to guarantee fixture selection stability across machines
and future parity PRs.

Comment on lines +38 to +44
# collect .cst/.csp/.csmap fixtures from extracted corpus
TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf "$TMPDIR_WORK"' EXIT

find "$CORPUS_DIR" -type f \( -name '*.cst' -o -name '*.csp' -o -name '*.csmap' \) | sort \
> "$TMPDIR_WORK/fixtures"

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--inventory is required but the script never reads it to select fixtures or validate corpus integrity; it just checks the file exists and then does find "$CORPUS_DIR" .... Either remove the --inventory argument entirely or use the lock file to drive fixture discovery so runs remain deterministic relative to the inventory.

Suggested change
# collect .cst/.csp/.csmap fixtures from extracted corpus
TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf "$TMPDIR_WORK"' EXIT
find "$CORPUS_DIR" -type f \( -name '*.cst' -o -name '*.csp' -o -name '*.csmap' \) | sort \
> "$TMPDIR_WORK/fixtures"
# collect .cst/.csp/.csmap fixtures from inventory, validating against corpus
TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf "$TMPDIR_WORK"' EXIT
python3 - "$INVENTORY" "$CORPUS_DIR" > "$TMPDIR_WORK/fixtures" << 'PYEOF'
import json
import os
import sys
if len(sys.argv) != 3:
sys.stderr.write("usage: python3 - <inventory.json> <corpus_dir>\n")
sys.exit(1)
inventory_path = sys.argv[1]
corpus_dir = sys.argv[2]
try:
with open(inventory_path, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as exc:
sys.stderr.write(f"failed to read inventory '{inventory_path}': {exc}\n")
sys.exit(1)
# Support either:
# - a top-level list of relative fixture paths, or
# - an object with a 'fixtures' key containing such a list.
if isinstance(data, dict) and "fixtures" in data:
fixtures = data["fixtures"]
elif isinstance(data, list):
fixtures = data
else:
sys.stderr.write(
"unsupported inventory format: expected list or object with 'fixtures' key\n"
)
sys.exit(1)
normalized = []
for item in fixtures:
if not isinstance(item, str):
sys.stderr.write("inventory entries must be strings (relative fixture paths)\n")
sys.exit(1)
# Only include known fixture extensions.
if not item.endswith((".cst", ".csp", ".csmap")):
continue
rel_path = item.lstrip("/")
full_path = os.path.normpath(os.path.join(corpus_dir, rel_path))
if not full_path.startswith(os.path.abspath(corpus_dir) + os.sep):
sys.stderr.write(f"inventory path escapes corpus dir: {item}\n")
sys.exit(1)
if not os.path.isfile(full_path):
sys.stderr.write(f"fixture not found in corpus: {item}\n")
sys.exit(1)
normalized.append(full_path)
for path in sorted(set(normalized)):
print(path)
PYEOF

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +174
# extract: extract corpus using the lock file
# ──────────────────────────────────────────────────────────────────────────────
cmd_extract() {
ZIP=""
LOCK=""
OUT_DIR=""

while [ $# -gt 0 ]; do
case "$1" in
--zip) ZIP="$2"; shift 2 ;;
--lock) LOCK="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
*) die "unknown option: $1" ;;
esac
done

[ -n "$ZIP" ] || die "--zip required"
[ -n "$LOCK" ] || die "--lock required"
[ -n "$OUT_DIR" ] || die "--out-dir required"
[ -f "$ZIP" ] || die "zip not found: $ZIP"
[ -f "$LOCK" ] || die "lock not found: $LOCK"

TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf "$TMPDIR_WORK"' EXIT

unzip -q "$ZIP" -d "$TMPDIR_WORK/outer"

# extract outer entries
find "$TMPDIR_WORK/outer" -type f | sort | while IFS= read -r f; do
REL="${f#$TMPDIR_WORK/outer/}"
EXT=$(printf '%s' "$REL" | sed 's/.*\.//' | tr '[:upper:]' '[:lower:]')
case ".$EXT" in
.cst|.csp|.csmap|.xsd|.xml|.json) ;;
*) continue ;;
esac
DST="$OUT_DIR/outer/$REL"
mkdir -p "$(dirname "$DST")"
cp "$f" "$DST"
done

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd_extract() claims to "extract corpus using the lock file" but it ignores the lock's entry list and hashes; it re-scans the unzipped contents and copies everything with allowed extensions. This defeats reproducibility/integrity guarantees (zip changes won't be detected, and extra/missing files aren't validated). Consider extracting only the lock-defined entries and verifying per-entry sha256 (and source zip sha256) like the prior implementation.

Copilot uses AI. Check for mistakes.

[ -n "$TOOL" ] || die "--tool required"
[ -n "$CORPUS_DIR" ] || die "--corpus-dir required"
[ -f "$TOOL" ] || die "tool not found: $TOOL"

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script verifies the tool exists with -f, but then executes it. If the file isn't executable (missing +x), the failure will be a confusing runtime error. Prefer checking -x "$TOOL" (or -f plus a clear chmod/message) before running.

Suggested change
[ -f "$TOOL" ] || die "tool not found: $TOOL"
[ -x "$TOOL" ] || die "tool not found or not executable: $TOOL"

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +178
NESTED_PREFIX=$(grep -o '"nested_zip_prefix":"[^"]*"' "$LOCK" 2>/dev/null \
| sed 's/"nested_zip_prefix":"//; s/"//' || printf 'Samples')

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested_zip_prefix extraction uses grep -o '"nested_zip_prefix":"[^"]*"', but the lock JSON written by cmd_inventory() includes spaces ("nested_zip_prefix": "..."). As written, this grep will fail and always fall back to Samples, ignoring custom prefixes. Update the pattern to tolerate whitespace or parse the JSON more robustly.

Suggested change
NESTED_PREFIX=$(grep -o '"nested_zip_prefix":"[^"]*"' "$LOCK" 2>/dev/null \
| sed 's/"nested_zip_prefix":"//; s/"//' || printf 'Samples')
NESTED_PREFIX=$(grep -o '"nested_zip_prefix"[[:space:]]*:[[:space:]]*"[^"]*"' "$LOCK" 2>/dev/null \
| sed 's/.*"nested_zip_prefix"[[:space:]]*:[[:space:]]*"//; s/".*$//' || printf 'Samples')

Copilot uses AI. Check for mistakes.
Comment thread Makefile

opensmithgen-ape:
@mkdir -p "$(BUILD_DIR)"
@cosmocc $(CFLAGS) \

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opensmithgen-ape invokes cosmocc directly without the discovery/error messaging used elsewhere (see the ape: target which falls back to ~/.cosmocc/bin/cosmocc and prints install steps). Consider reusing that detection logic here so make opensmithgen-ape fails with a clear, actionable message when cosmocc isn't installed.

Suggested change
@cosmocc $(CFLAGS) \
@COSMOCC=$$( \
if command -v cosmocc >/dev/null 2>&1; then \
printf '%s' "cosmocc"; \
elif [ -x "$$HOME/.cosmocc/bin/cosmocc" ]; then \
printf '%s' "$$HOME/.cosmocc/bin/cosmocc"; \
else \
printf 'cosmocc not found.\n' >&2; \
printf 'To build APE tools you need cosmocc installed.\n' >&2; \
printf 'Either:\n' >&2; \
printf ' - Install cosmocc and ensure it is on your PATH, or\n' >&2; \
printf ' - Install it under $$HOME/.cosmocc/bin/cosmocc.\n' >&2; \
exit 1; \
fi \
); \
"$$COSMOCC" $(CFLAGS) \

Copilot uses AI. Check for mistakes.
Comment thread Makefile
Comment on lines +389 to +392
opensmith-frontend-check: opensmithgen-ape opensmith-corpus
@sh ./scripts/opensmith_frontend_check.sh \
--tool "$(OPENSMITH_FRONTEND_TOOL)" \
--corpus-dir "$(OPENSMITH_CORPUS_DIR)"

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions a Python-based opensmith_frontend_check harness, but this PR removes the Python scripts and wires in a POSIX sh implementation instead (scripts/opensmith_frontend_check.sh, opensmith_parity.sh, opensmith_corpus.sh). Update the PR description to match the actual implementation/language so reviewers/users aren't misled.

Copilot uses AI. Check for mistakes.
Comment on lines +559 to +575
for (i = 0; i < doc->count; i++) {
const ast_node_t *n = &doc->nodes[i];
size_t raw_len = n->raw ? strlen(n->raw) : 0;
if (raw_len > ((size_t)-1) - total - 1) {
fprintf(stderr, "error: output too large\n");
return -1;
}
total += raw_len;
}

buf = (char *)xmalloc(total + 1);
for (i = 0; i < doc->count; i++) {
const ast_node_t *n = &doc->nodes[i];
size_t raw_len = n->raw ? strlen(n->raw) : 0;
if (raw_len > 0) {
memcpy(buf + off, n->raw, raw_len);
off += raw_len;

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reconstruct() computes each node's contribution using strlen(n->raw), but raw is built from byte slices and may legally contain \0 (files are read in binary mode). This can truncate output and make roundtrip checks incorrect. Prefer tracking raw byte lengths (e.g., n->end - n->start) or storing explicit raw_len alongside raw and using that instead of strlen.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +75
case "$ENGINE" in
*'{input}'*) CMD=$(printf '%s' "$ENGINE" | sed "s|{input}|$fixture|g") ;;
*) CMD="$ENGINE $fixture" ;;
esac

OUT_FILE="$ARTIFACTS_DIR/$REL.stdout"
ERR_FILE="$ARTIFACTS_DIR/$REL.stderr"
CODE_FILE="$ARTIFACTS_DIR/$REL.exitcode"

mkdir -p "$(dirname "$OUT_FILE")"

RC=0
sh -c "$CMD" > "$OUT_FILE" 2> "$ERR_FILE" || RC=$?

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building the CMD string by concatenating ENGINE with the unescaped fixture path and then executing it via sh -c allows shell metacharacters in corpus file names to break out of the intended engine command. An attacker who can control entries in the corpus ZIP can craft fixture names like foo.cst; rm -rf / so that when sh -c "$CMD" runs, arbitrary shell commands execute with the privileges of this harness. To harden this, avoid sh -c and pass the engine and input path as separate argv elements (or robustly quote the fixture path) so that file names cannot alter the shell command structure.

Copilot uses AI. Check for mistakes.
@ludoplex

ludoplex commented Mar 5, 2026

Copy link
Copy Markdown
Owner Author

@copilot open a new pull request to apply changes based on the comments in this thread except remove any and all python

Copilot AI commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

@ludoplex I've opened a new pull request, #8, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Comment on lines +59 to +63
TMPDIR_WORK=$(mktemp -d)
trap 'rm -rf "$TMPDIR_WORK"' EXIT

unzip -q "$ZIP" -d "$TMPDIR_WORK/outer"

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These unzip extractions trust the archive’s paths and extract directly into a temp directory. A crafted zip with absolute paths or ../ components can perform a zip-slip write outside the temp directory. If OPENSMITH_ZIP is ever untrusted (or simply corrupted), this is a filesystem write vulnerability. Consider pre-validating the zip member list (e.g., via unzip -Z1) to reject unsafe paths before extraction, or use an extraction mode/tool that enforces path safety.

Copilot uses AI. Check for mistakes.
@ludoplex

ludoplex commented Mar 8, 2026

Copy link
Copy Markdown
Owner Author

📋 Review Triage — What PR #8 Covers vs Still Open

✅ Already addressed by PR #8

  • Trailing -- in comment bodies
  • raw_len for byte-accurate reconstruction (no more strlen)
  • Empty final literal segments
  • Shell injection in parity.sh
  • -f vs -x check in frontend_check.sh
  • nested_zip_prefix grep whitespace
  • Makefile cosmocc detection

🔴 Still open (not in PR #8)

  1. P1: classify_node misclassifies <% --i; %> — Any block starting with -- is treated as comment. Need to distinguish <%-- comment delimiter from <% statement containing -- prefix operator
  2. zip-slip in opensmith_corpus.shunzip trusts archive paths; need path sanitization
  3. safe_relpath() Windows backslash bypassPurePosixPath doesn't split backslashes on Windows (low priority for cosmo builds)
  4. --inventory not used in parity.sh — Lock file exists-check only; should drive fixture discovery
  5. cmd_extract() ignores lock hashes — Re-scans instead of verifying integrity
  6. subprocess security in Python scriptsrun() with dynamic strings (audit flagged)
  7. PR description mentions Python; scripts are POSIX sh — Description needs update
  8. if (tag_start != i) always-true guard — Cleanup dead condition at EOF

Items 1 and 2 are the highest priority (correctness + security). Will prepare patches.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants