mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
git diff on a lockfile is unreadable: npm reorders entries, rewrites
integrity hashes, and moves packages between nesting levels, so a
one-line package.json bump produces a thousand-line textual diff.
scripts/ci/lockfile_diff.py instead parses the `packages` map out of
both versions of every tracked package-lock.json (via `git show`),
reduces each to {install path: version}, and set-diffs the maps —
reorder/hash churn vanishes, leaving only actual version movement
(added / removed / updated, with nested dedup copies tracked
separately).
The lockfile-diff workflow posts the result as a Markdown table in a
PR comment gated behind a hidden marker: subsequent pushes PATCH the
existing comment instead of stacking new ones, and a push that reverts
all lockfile changes updates the comment to say so. Advisory only —
never fails on findings; fork PRs (read-only token) degrade to a
warning.
Wired through the ci.yml orchestrator with a new npm_lock lane in
classify_changes.py (fails open on .github/ changes per the existing
contract).
132 lines
5.1 KiB
Python
132 lines
5.1 KiB
Python
"""Tests for scripts/ci/classify_changes.py.
|
|
|
|
Check some common patterns of file modifications and the CI lanes they should run.
|
|
We should always fail open. We may run a lane we didn't need, never skip one a
|
|
change could have broken.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "classify_changes.py"
|
|
_spec = importlib.util.spec_from_file_location("classify_changes", _PATH)
|
|
if _spec is None or _spec.loader is None:
|
|
raise ImportError("Failed to load classify_changes.py")
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_mod)
|
|
classify = _mod.classify
|
|
|
|
DEFAULT = {
|
|
"python": True,
|
|
"frontend": True,
|
|
"docker_meta": True,
|
|
"site": True,
|
|
"scan": True,
|
|
"deps": True,
|
|
"npm_lock": True,
|
|
"mcp_catalog": False,
|
|
"ci_review": True,
|
|
}
|
|
|
|
|
|
def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, mcp_catalog=False, docker_meta=False, ci_review=False) -> dict[str, bool]:
|
|
return {
|
|
"python": python,
|
|
"frontend": frontend,
|
|
"docker_meta": docker_meta,
|
|
"site": site,
|
|
"scan": scan,
|
|
"deps": deps,
|
|
"npm_lock": npm_lock,
|
|
"mcp_catalog": mcp_catalog,
|
|
"ci_review": ci_review,
|
|
}
|
|
|
|
|
|
CASES = {
|
|
"docs-only → nothing heavy": (["README.md", "docs/guide.md"], _lanes()),
|
|
"python source → python": (["run_agent.py"], _lanes(python=True, scan=True)),
|
|
"dep manifest → python": (["pyproject.toml"], _lanes(python=True, scan=True, deps=True)),
|
|
"uv.lock → python": (["uv.lock"], _lanes(python=True)),
|
|
"ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)),
|
|
"ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)),
|
|
# Lockfile bump shifts every TS package's tree, but not the Python suite.
|
|
"root lockfile → frontend, not python": (["package-lock.json"], _lanes(frontend=True, npm_lock=True)),
|
|
"nested lockfile → npm_lock": (["website/package-lock.json"], _lanes(site=True, npm_lock=True)),
|
|
"website → site": (["website/docs/intro.md"], _lanes(site=True)),
|
|
# SKILL.md reads like docs, but the skill-doc tests read skills/, so a
|
|
# skill edit must still run Python.
|
|
"skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)),
|
|
"dockerfile → docker meta": (["Dockerfile"], _lanes(docker_meta=True)),
|
|
# Unknown top-level file keeps Python on rather than risk a silent skip.
|
|
"unknown toplevel → python": (["Makefile"], _lanes(python=True)),
|
|
"mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True, scan=True)),
|
|
"mixed docs+frontend → frontend": (["README.md", "apps/x.tsx"], _lanes(frontend=True)),
|
|
# Supply-chain lanes
|
|
".pth file → scan": (["evil.pth"], _lanes(python=True, scan=True)),
|
|
"setup.py → scan": (["setup.py"], _lanes(python=True, scan=True)),
|
|
"mcp catalog manifest → mcp_catalog": (
|
|
["optional-mcps/foo/manifest.yaml"],
|
|
_lanes(python=True, mcp_catalog=True),
|
|
),
|
|
"mcp_catalog.py → mcp_catalog": (
|
|
["hermes_cli/mcp_catalog.py"],
|
|
_lanes(python=True, scan=True, mcp_catalog=True),
|
|
),
|
|
# CI-sensitive files require explicit review label.
|
|
"eslint config → ci_review": (
|
|
["apps/desktop/eslint.config.mjs"],
|
|
_lanes(frontend=True, ci_review=True),
|
|
),
|
|
"shared eslint config → ci_review": (
|
|
["eslint.config.shared.mjs"],
|
|
_lanes(python=True, ci_review=True),
|
|
),
|
|
"ui-tui eslint config → ci_review": (
|
|
["ui-tui/eslint.config.mjs"],
|
|
_lanes(frontend=True, ci_review=True),
|
|
),
|
|
"web eslint config → ci_review": (
|
|
["web/eslint.config.js"],
|
|
_lanes(frontend=True, ci_review=True),
|
|
),
|
|
"shared package eslint config → ci_review": (
|
|
["apps/shared/eslint.config.mjs"],
|
|
_lanes(frontend=True, ci_review=True),
|
|
),
|
|
"bootstrap-installer eslint config → ci_review": (
|
|
["apps/bootstrap-installer/eslint.config.mjs"],
|
|
_lanes(frontend=True, ci_review=True),
|
|
),
|
|
"prettier config → ci_review": (
|
|
[".prettierrc"],
|
|
_lanes(python=True, ci_review=True),
|
|
),
|
|
"workflow yml → ci_review (also fail-open all)": (
|
|
[".github/workflows/typecheck.yml"],
|
|
DEFAULT,
|
|
),
|
|
"composite action → ci_review (also fail-open all)": (
|
|
[".github/actions/retry/action.yml"],
|
|
DEFAULT,
|
|
),
|
|
# Normal desktop source doesn't trigger ci_review.
|
|
"desktop src → no ci_review": (
|
|
["apps/desktop/src/app.tsx"],
|
|
_lanes(frontend=True),
|
|
),
|
|
# Fail open: CI-config / empty / blank diffs run everything.
|
|
".github change → all": ([".github/workflows/tests.yml"], DEFAULT),
|
|
"action change → all": ([".github/actions/detect-changes/action.yml"], DEFAULT),
|
|
"empty diff → all": ([], DEFAULT),
|
|
"blank lines → all": (["", " "], DEFAULT),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("files,expected", CASES.values(), ids=CASES.keys())
|
|
def test_classify(files, expected):
|
|
assert classify(files) == expected
|