Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c37c9d6
fix: reject non-dict frontmatter with a clean ParseError
Jul 9, 2026
f69ad0c
fix: escape literal ellipsis in template placeholder pattern
Jul 9, 2026
40e3d24
fix: reject multi-skill targets for ingest flags
Jul 9, 2026
ed28f6f
fix: raise LedgerError on malformed history ledgers
Jul 9, 2026
0fef516
fix: dedupe allowed-tools before graph node creation
Jul 9, 2026
606e162
fix: handle unparseable files in emit and re-parse modes
Jul 9, 2026
1ae7bdb
fix: label Codex provenance with its own data date
Jul 9, 2026
7acfa99
chore: sync README test count and import order for phase 1
Jul 9, 2026
590c8e6
fix: sanitize terminal control chars from ingested responses
Jul 9, 2026
d73755f
fix: bound the size of ingested agent responses
Jul 9, 2026
ef27fe8
style: drop unused Path import in sanitization test
Jul 9, 2026
d6511a7
fix: tighten published schemas to match parser strictness
Jul 9, 2026
d03555b
fix: pass action inputs through env to stop shell injection
Jul 9, 2026
2366774
ci: add tag-triggered release workflow with real attestation
Jul 9, 2026
b05f848
ci: pin all third-party actions to commit SHAs
Jul 9, 2026
a164ba2
test: add pre-commit to the dev extra so hook tests run in CI
Jul 9, 2026
72d543a
build: fix verify-release build gate and add rev drift check
Jul 9, 2026
e7798fb
chore: sync README test count for phase 2
Jul 9, 2026
eb446f9
refactor: dedupe frontmatter-block extraction to one helper
Jul 9, 2026
98eea0f
refactor: share require_field and JSON decode across ingest parsers
Jul 9, 2026
40d0f6c
refactor: remove dead reporter module and critique-merge shim
Jul 9, 2026
bcbae46
refactor: hoist mode-conflict table and checker to module level
Jul 9, 2026
23051f4
refactor: split capability graph model out of graph.py
Jul 9, 2026
5ca9843
refactor: split ledger filesystem I/O into history_io
Jul 9, 2026
b7b6e4f
refactor: split run_validation into exit-code, history, print phases
Jul 9, 2026
c5980f8
build: gate scripts/ under ruff and mypy
Jul 9, 2026
e29ed14
fix: correct GitHub Actions annotation escaping
Jul 9, 2026
329a96d
fix: detect YAML anchors via event stream, not regex
Jul 9, 2026
07e3694
fix: fsync ledger writes and sweep stale temp files
Jul 9, 2026
c4dc276
fix: harden config_loader parsing and discovery
Jul 9, 2026
1654c84
fix: recognize empty frontmatter and stop following dir symlinks
Jul 9, 2026
b9bcc8f
fix: size markdown tables individually in body-bloat check
Jul 9, 2026
e6dabad
docs: correct tiktoken offline claim, add Typed classifier
Jul 9, 2026
1feff3e
fix: emit reference paths relative to the skill dir
Jul 9, 2026
63e7dfd
chore: release 1.4.1
Jul 9, 2026
1bc5d07
chore: track and gate the case-study report script
Jul 9, 2026
1687a85
chore: gitignore per-skill .skillcheck-history.json ledgers
Jul 9, 2026
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
39 changes: 13 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -37,36 +37,34 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"

- name: Install dev dependencies
run: pip install -e ".[dev]"

- name: Ruff check
run: ruff check src tests
run: ruff check src tests scripts

- name: Mypy strict
run: mypy src/skillcheck
# Bare mypy uses the [tool.mypy] files list (src plus the checked-in
# scripts), so the utility scripts are type-gated alongside the package.
run: mypy

package:
# Smoke-check that the project builds and the wheel installs on every push
# and PR. Attestation and PyPI publishing happen only on tag pushes, in
# release.yml, so this job stays read-only.
runs-on: ubuntu-latest
# Attestation steps below need to issue an OIDC token and write the
# attestation; both default to off in the workflow-level permissions
# block. Job-scoped overrides keep the rest of the workflow read-only.
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"

Expand All @@ -80,14 +78,3 @@ jobs:
run: |
pip install dist/skillcheck-*.whl
skillcheck --version

- name: Attest build provenance
# Only attest tagged releases. Attestations on every CI run would
# flood the project's attestation feed and are not signed against
# an intended release artifact.
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/attest-build-provenance@v1
with:
subject-path: |
dist/skillcheck-*.whl
dist/skillcheck-*.tar.gz
4 changes: 2 additions & 2 deletions .github/workflows/release-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
promote-unreleased:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: main
fetch-depth: 0
Expand Down Expand Up @@ -77,7 +77,7 @@ jobs:

- name: Open PR with the promoted CHANGELOG
if: steps.promote.outputs.result == 'promoted'
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
with:
commit-message: |
docs(changelog): promote [Unreleased] to [${{ github.event.release.tag_name }}]
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Release

# Publishes to PyPI and attests build provenance when an immutable version tag
# is pushed. The v*.*.* filter matches patch tags (v1.2.3) but not the moving
# major tag (v1), so the moving tag does not trigger a duplicate publish.
on:
push:
tags:
- 'v*.*.*'

permissions:
contents: read

jobs:
release:
runs-on: ubuntu-latest
# Trusted publishing resolves against this environment on the PyPI side.
environment: pypi
permissions:
contents: read
id-token: write # PyPI trusted publishing (OIDC) and provenance signing
attestations: write # actions/attest-build-provenance
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"

- name: Install build tools
run: pip install hatchling

- name: Build sdist and wheel
run: python -m hatchling build

- name: Verify wheel installs and version matches the tag
run: |
set -euo pipefail
pip install dist/skillcheck-*.whl
installed="$(skillcheck --version | awk '{print $NF}')"
tag="${GITHUB_REF_NAME#v}"
if [ "$installed" != "$tag" ]; then
echo "Built version '$installed' does not match tag '$tag'." >&2
echo "Bump the version in pyproject.toml and __init__.py before tagging." >&2
exit 1
fi

- name: Attest build provenance
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: |
dist/skillcheck-*.whl
dist/skillcheck-*.tar.gz

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@7f25271a4aa483500f742f9492b2ab5648d61011 # v1.12.4
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ launch-post/
LAUNCH_CHECKLIST.md
LAUNCH_POST_*.md

# Per-skill history ledgers written by `skillcheck --history`; running the tool
# against a file in the repo drops one next to it and it must never be committed.
.skillcheck-history.json

# Verification venvs (created by end-to-end verification runs)
.venv-verify/

Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added the `Typing :: Typed` classifier so PyPI reflects the shipped `py.typed` marker.

### Fixed

- Empty frontmatter (`---\n---`) is now recognized instead of leaking the delimiters into the body and its line count.
- Directory scanning uses `os.walk(followlinks=False)` instead of `Path.rglob`, so a directory symlink cannot pull in files from another tree or hang the scan on a symlink cycle.
- `disclosure.body-bloat` sizes each markdown table independently. Previously every `|`-row in the body was summed as one table, so several small tables could report a false oversized-table count.
- Broken-reference and reference-escape diagnostics now show the resolved path relative to the skill directory (`scripts/foo.py`, or `../..` for an escape) instead of the absolute host path, so CI logs no longer leak the build machine's directory layout.
- Corrected the `estimate_tokens` docstring: tiktoken downloads its vocabulary on first use, so it is not "fully offline" until the cache is warm; the whitespace fallback is always offline.
- `skillcheck.toml` handling improved: config type errors now name the offending value; the Python 3.10 fallback parser no longer truncates a value at a `#` inside quotes; `find_config` stops ascending at a `.git` repository root or the user's home instead of walking to the filesystem root; and the CLI prints which config file it loaded (to stderr) when one is found.
- History ledger writes are more durable: `save_ledger` now flushes and `fsync`s the temp file before the atomic `os.replace`, and `load_ledger` sweeps stale `.skillcheck-tmp-*` files left behind by an interrupted write. The module documents its single-writer assumption (no file locking).
- `frontmatter.yaml-anchors` no longer false-positives on `&` or `*` inside quoted string values. A description like `"Reviews R&D notes and *only* flags risky items"` used to match the anchor/alias regexes; detection now walks the YAML event stream, so only real anchors and aliases are reported.
- `--format github` no longer over-escapes diagnostic message text. Message data now escapes only `%`, CR, and LF (per the GitHub Actions toolkit), so a colon or comma in a message renders literally instead of as `%3A`/`%2C`. Property values (`file`, `title`) still escape `:` and `,` as required, which also fixes the previously unescaped colon in the annotation title.

## [1.4.1] - 2026-07-09

### Added

- `[tool.ruff]` and `[tool.mypy]` configuration in `pyproject.toml`. Ruff lints `src` and `tests` with an explicit `E, F, I, UP, B` selection at line length 127 (`E501` ignored for unavoidable long string literals in JSON fixtures and schema text). Mypy runs `strict` against `src/skillcheck` under `python_version = "3.10"`, with an `ignore_missing_imports` override for the untyped `tiktoken` dependency and the `tomllib` 3.11+ stdlib backport branch. The source was brought clean under both with real annotations, not blanket ignores.
- Test coverage measurement via `pytest-cov` (added to the `dev` extra). `[tool.coverage.run]` and `[tool.coverage.report]` configure the run, and `addopts` in `[tool.pytest.ini_options]` adds `--cov=skillcheck --cov-report=term-missing --cov-fail-under=68`, so the floor is enforced on every local run and in CI (which runs the same `pytest`). The floor sits a few points under the ~72% measured on CPython 3.10 to absorb matrix variance: the CLI modules run in subprocesses the in-process tracer does not see, the `tomllib` vs fallback-parser branch flips between Python 3.10 and 3.11+, and a few tests skip on Windows.

### Changed

- CI `lint` job now enforces `ruff check src tests` and `mypy src/skillcheck` (strict) on every push and pull request, replacing the prior `compileall`-only check. Either tool reporting a finding fails the job.
- `cli.py` split to separate argument wiring from command execution. Parser construction, `skillcheck.toml` application, mode-conflict dispatch, and `main` stay in `cli.py`; the per-mode handlers (emit prompts and graphs, `--show-history`, the default validation pipeline) and the path and ingest IO helpers move to a new `skillcheck.commands` module. `skillcheck.cli:main` and the `skillcheck` console script are unchanged. Pure refactor, no behavior change.
- The published JSON Schemas (`critique-v1.json`, `graph-v1.json`) now set `additionalProperties: false` on every object, matching the parsers, which already reject unknown fields. An agent that validates its output against the schema no longer produces responses that pass the schema but fail ingest.
- Release automation moved to a dedicated tag-triggered `release.yml`. It builds the wheel and sdist, verifies the built version matches the tag, attests build provenance, and publishes to PyPI through trusted publishing. The dead attest step in `ci.yml` (gated on tags but on a workflow that never runs on tags) was removed, so the README's provenance claim is now backed by a workflow that actually runs. `CONTRIBUTING.md` documents the one-time PyPI trusted-publishing setup.
- Every third-party GitHub Action is now pinned to a full commit SHA (with a trailing version comment) across all workflows and `action.yml`, replacing floating major-version tags. This closes the supply-chain window where a compromised or force-moved tag could run with the workflows' write permissions.
- `pre-commit` is now part of the `dev` extra, so the two `test_pre_commit.py` end-to-end hook tests actually run in CI instead of silently skipping.
- `make verify-release` actually builds the sdist and wheel now. The old guard used `command -v python3 -c "import build"`, which only resolved the `python3` path and never tested the `build` module, so the build verification was effectively meaningless. The target also gained a drift grep asserting the README's pre-commit `rev:` matches the pyproject version, and the README `rev:` was corrected from `v1.3.0` to `v1.4.0`.
- Internal refactors, no behavior change: the three copies of frontmatter-block extraction (`_frontmatter_block`, `_extract_frontmatter_raw`, `_extract_frontmatter_text`) are deduplicated to the single `frontmatter_common._frontmatter_block`.
- The critique and graph parsers now share `require_field` and `decode_json_or_raise` from `agents/_ingest.py` instead of each carrying its own copy. As a side effect the critique JSON-decode error message adopts the graph parser's wording (exception type and diagnostic content unchanged).
- Dead code removed: the unused `core/reporter.py` module (the CLI renders through `formatters.py`) and the `merge_critique_diagnostics` shim (identical to `merge_diagnostics`, now called directly). No public CLI or `skillcheck.validate` behavior changes.
- Oversized modules decomposed with no behavior change: the CLI mode-conflict table and checker are hoisted to module level; `run_validation` is split into `_compute_exit_code`, `_record_history`, and `_print_report`; the capability-graph data model moves to `core/graph_model.py`; and the ledger filesystem I/O moves to `core/history_io.py`. All original import paths still resolve via re-exports.
- Quality gates extended to `scripts/`: CI and the `Makefile` now run `ruff check src tests scripts`, and mypy's `files` list includes the checked-in utility scripts so they are type-gated alongside the package. `scripts/summarize_batch.py` was brought clean under both.

### Fixed

- Non-dict frontmatter (a bare scalar or list between the `---` delimiters) no longer crashes with an `AttributeError` traceback. `parser.parse` now raises `ParseError` naming the actual YAML type and the path, which the validation pipeline renders as a clean `parse.error` diagnostic and exit 1.
- Template detection no longer misreads bracketed acronyms as placeholders. The `[...]` branch of the placeholder pattern was unescaped, so `[ISO]`, `[API]`, and `[CLI]` in a real description matched and silently suppressed the deployment-blocking ERROR checks (`frontmatter.name.directory-mismatch`, `compat.vscode-dirname`, description scoring). The literal three-dot placeholder is now matched exactly.
- `--ingest-critique` and `--ingest-graph` now reject a multi-skill target instead of stamping the first skill's ingested diagnostics onto every file. An agent response describes one skill, so pointing an ingest flag at a directory that resolves to more than one SKILL.md exits `2` with an error naming the flag and the path count.
- A malformed history ledger now raises a clean `LedgerError` instead of an uncaught `TypeError`. `load_ledger` validates that the JSON root is an object, that `runs` is a list, and that each run entry is well-formed, and it now checks the `version` field against `LEDGER_SCHEMA_VERSION` (previously read but never enforced), naming both versions on mismatch. Every failure carries the "delete it and re-run with --history" remediation.
- `--analyze-graph` no longer crashes on a repeated tool. `allowed-tools: [Bash, Bash]` minted two graph nodes with the same content-hash ID, tripping the duplicate-node-ID guard with an uncaught `ValueError`. The heuristic extractor now dedupes tool names before building nodes.
- Emit and re-parse modes no longer surface a traceback on a file that plain validation handles cleanly. `--emit-graph`, `--emit-critique-prompt`, `--emit-graph-prompt`, `--agent-reason`, `--activation-hypotheses`, `--analyze-graph`, `--history`, and the `--ingest-*` first-path re-parse now catch `ParseError`, print the message to stderr, and exit 1. `read_ingest_raw` additionally catches `UnicodeDecodeError`, so a non-UTF-8 ingest response file takes the clean exit-2 path instead of crashing.
- Codex compatibility provenance now carries its own `_CODEX_DATA_DATE` constant instead of borrowing `_CLAUDE_DATA_DATE`. The mislabel was invisible because all three provenance dates were equal; the date reported for Codex is now sourced from and freshness-checked against the Codex constant independently.

### Security

- `action.yml` no longer interpolates user-controlled inputs into the shell script. Every input is passed through an `env:` mapping and referenced as `"$INPUT_*"`, so a crafted input value cannot break out of the `run:` block and execute arbitrary commands. Behavior is otherwise identical.
- Ingested critique and graph responses are treated as untrusted input: strings taken from them (missing-context items, contradiction locations, findings, and graph node names) are stripped of terminal control characters before they reach the human-readable report. A response carrying raw ANSI escapes can no longer forge terminal output (a fake `PASS` line, a cleared screen). Control characters are rendered in a visible backslash-escaped form rather than dropped. The JSON output path is unchanged (`json.dumps` already escapes control characters).
- Ingested responses are size-bounded. A response file or stdin payload over 5 MiB (`MAX_INGEST_BYTES`) is rejected with exit 2 before it is read into memory, and any single response list (findings, missing_context, contradictions, capabilities, inputs, outputs, edges) over 10,000 items (`MAX_INGEST_LIST_ITEMS`) is rejected with a clear error. Both messages name the actual size/count and the cap.

## [1.4.0] - 2026-05-27

Expand Down
23 changes: 18 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,34 @@ A handful of tests skip on Windows because the underlying OS feature is unavaila

## Releasing

Publishing is automated. Pushing an immutable patch tag (`v1.2.3`) triggers `.github/workflows/release.yml`, which builds the wheel and sdist, attests build provenance with `actions/attest-build-provenance`, and publishes to PyPI via trusted publishing (`pypa/gh-action-pypi-publish`). No manual `twine upload` or API token is involved. The moving `v1` tag is filtered out (`v*.*.*`) so it does not trigger a second publish.

Every release pushes two tags pointing to the same commit:

1. An immutable patch tag (e.g., `v1.2.3`).
1. An immutable patch tag (e.g., `v1.2.3`) that drives the release workflow.
2. A force-updated `v1` moving major tag pointing to the same commit.

Steps:

```bash
# Bump version in pyproject.toml, commit, push to main.
# Bump version in pyproject.toml, __init__.py, and CHANGELOG.md together, commit, push to main.
git push origin main
git tag v1.2.3
git push origin v1.2.3
git push origin v1.2.3 # release.yml builds, attests, and publishes to PyPI
git tag -f v1
git push origin v1 --force
git push origin v1 --force # moving tag; filtered out of release.yml
gh release create v1.2.3 --title "v1.2.3" --notes-file CHANGELOG_ENTRY.md
```

The `v1` tag always tracks the latest patch in the v1.x line. This lets GitHub Action users pin `@v1` for automatic updates or `@v1.2.3` for an immutable pin.
The `v1` tag always tracks the latest patch in the v1.x line. This lets GitHub Action users pin `@v1` for automatic updates or `@v1.2.3` for an immutable pin.

### One-time PyPI trusted-publishing setup

Trusted publishing must be configured once on the PyPI side before the first automated release. In the `skillcheck` project settings on PyPI, add a GitHub Actions publisher with:

- Owner: `moonrunnerkc`
- Repository: `skillcheck`
- Workflow filename: `release.yml`
- Environment: `pypi`

Until this is configured, the `Publish to PyPI` step will fail with an OIDC trust error; the build and attestation steps still run.
Loading
Loading