-
Notifications
You must be signed in to change notification settings - Fork 4
chore(maintenance): reproducible workflow/script consumer inventory #422
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # ARSAS maintainability — workflow/script consumer inventory | ||
|
|
||
| This is an executable, review-first follow-up to [the baseline audit](MAINTAINABILITY_BASELINE_2026-09-27.md) and [issue #380](https://github.com/masarray/arsas/issues/380). It does not replace the existing architecture contract or authorize script deletion. | ||
|
|
||
| ## Reproduce | ||
|
|
||
| On a repository checkout, using Python 3.11+ and Git: | ||
|
|
||
| ```powershell | ||
| python .\scripts\test-maintenance-dependencies.py | ||
| python .\scripts\audit-maintenance-dependencies.py --root . --output maintenance-dependencies.json | ||
| ``` | ||
|
|
||
| The canonical Windows Build ARSAS workflow runs both commands against its **exact triggering Git SHA** and uploads the JSON as `ARSAS-maintenance-dependency-inventory`. The inventory is generated outside the tracked tree and does not change any application or release asset. | ||
|
|
||
| ## What the report proves — and does not prove | ||
|
|
||
| - Lists Git-tracked workflows, top-level trigger types, Git-tracked scripts and **literal textual references** from workflow and other source/documentation files. | ||
| - Separates direct workflow references from other tracked references; stores a source commit so the map can be compared across candidates. | ||
| - `requiresManualConsumerReview` means **no literal reference was found in scanned tracked text**. It does **not** mean an entry point is orphaned or safe to delete. Dynamic invocation, branch-specific scripts, workflow_dispatch, local/operational calls, generated jobs and external consumers may not be visible. | ||
| - Neither keyword matching nor a successful build proves ownership of an asset, a security boundary, or the absence of historical callers. Complete those reviews separately. | ||
|
|
||
| ## Deletion/refactor gate for each candidate | ||
|
|
||
| 1. Identify its owner and actual inputs/outputs, event trigger(s), exact call sites, permissions and side effects (including Git tags, Releases, Pages, SBOM and evidence writes). | ||
| 2. Check manual and out-of-repository uses with the maintainer, archived references, active workflow branches and release provenance; retain unknowns rather than treating them as zero consumers. | ||
| 3. If retiring an entry point, remove it together with all **verified** callers, documentation and tests in one bounded PR. Do not alter historical tags/artifacts or silently weaken required checks. | ||
| 4. Run exact-head source clean, consumer tests, full Windows build, portable smoke and post-merge verification. Changes to live IEC 61850 paths require separate targeted physical evidence. | ||
|
|
||
| ## Implementation order | ||
|
|
||
| P1: classify the report's consumers and unknowns, plus review existing asset-provenance dispositions. P2: eliminate only verified dead automation and duplicate source-of-truth declarations. P3: extract one pure tested semantic/persistence component at a time. P4: characterize facade/core/client session lifecycle deterministically before moving ownership. P5: simplify UI projection without shifting protocol truth into WPF. Stage main protection after checking bot writers and recovery access. | ||
|
|
||
| The v1.6.40 accepted release and its engine lock are not a pending bug fix under this maintenance program. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| #!/usr/bin/env python3 | ||
| """Read-only, deterministic inventory of Git-tracked workflow/script references. | ||
|
|
||
| A textual reference does not prove execution. No reference does not prove a file | ||
| is unused. This report must never be used as an automatic deletion list. | ||
| """ | ||
| from __future__ import annotations | ||
| import argparse | ||
| import json | ||
| import re | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| TEXT_EXT = frozenset((".cs", ".csproj", ".props", ".targets", ".sln", ".slnx", | ||
| ".md", ".txt", ".ps1", ".py", ".bat", ".cmd", ".yml", ".yaml", ".json", | ||
| ".xml", ".xaml", ".iss", ".html", ".js", ".css", ".tmpl", ".cfg")) | ||
| SCRIPT_EXT = frozenset((".py", ".ps1", ".bat", ".cmd")) | ||
| NAMES = frozenset(("CODEOWNERS", "LICENSE", "NOTICE", "VERSION", ".editorconfig", | ||
| ".gitignore", ".gitattributes")) | ||
|
|
||
| def git(root: Path, *args: str) -> bytes: | ||
| return subprocess.check_output(("git", "-C", str(root), *args), stderr=subprocess.PIPE) | ||
|
|
||
| def paths(root: Path) -> list[str]: | ||
| return sorted({p.decode("utf-8", "surrogateescape").replace("\\", "/") | ||
| for p in git(root, "ls-files", "-z").split(b"\0") if p}) | ||
|
|
||
| def events(content: str) -> list[str]: | ||
| found: set[str] = set() | ||
| in_on = False | ||
| for line in content.splitlines(): | ||
| if re.match(r"^on:\s*(?:#.*)?$", line): | ||
| in_on = True | ||
| continue | ||
| inline = re.match(r"^on:\s*\[([^]]+)\]", line) | ||
| if inline: | ||
| found.update(x.strip().strip("'\"") for x in inline.group(1).split(",") if x.strip()) | ||
| break | ||
| if in_on and line and not line[0].isspace() and not line.lstrip().startswith("#"): | ||
| break | ||
| if in_on: | ||
| match = re.match(r"^ ([\w-]+):(?:\s|$)", line) | ||
| if match: | ||
| found.add(match.group(1)) | ||
| return sorted(found) | ||
|
|
||
| def inventory(root: Path) -> dict: | ||
| tracked = paths(root) | ||
| scripts = sorted(p for p in tracked if p.startswith("scripts/") and | ||
| Path(p).suffix.lower() in SCRIPT_EXT) | ||
| workflows = sorted(p for p in tracked if p.startswith(".github/workflows/") and | ||
| Path(p).suffix.lower() in (".yml", ".yaml")) | ||
| patterns = {p: re.compile(r"(?<![\w.\-])(?:scripts[\\/])?" + | ||
| re.escape(Path(p).name) + r"(?![\w\-]|\.[\w\-])", re.IGNORECASE) | ||
| for p in scripts} | ||
| refs: dict[str, list[str]] = {p: [] for p in scripts} | ||
| trigger_map: dict[str, list[str]] = {} | ||
| scanned = 0 | ||
| for path in tracked: | ||
| if Path(path).suffix.lower() not in TEXT_EXT and Path(path).name not in NAMES: | ||
| continue | ||
| full = root / path | ||
| if not full.is_file(): | ||
| raise FileNotFoundError("Tracked text file missing: " + path) | ||
| content = full.read_text(encoding="utf-8-sig", errors="replace") | ||
|
Comment on lines
+63
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the documented command is run from a checkout containing staged or unstaged tracked edits, this reads the worktree files while AGENTS.md reference: AGENTS.md:L27-L33 Useful? React with 👍 / 👎. |
||
| scanned += 1 | ||
| if path in workflows: | ||
| trigger_map[path] = events(content) | ||
| for script, pattern in patterns.items(): | ||
| if path != script and pattern.search(content): | ||
| refs[script].append(path) | ||
| result = { | ||
| "schemaVersion": 1, | ||
| "sourceCommit": git(root, "rev-parse", "HEAD").decode("ascii").strip(), | ||
| "scope": "Tracked textual references only; manual, dynamic and external consumers require review", | ||
| "counts": {"trackedPaths": len(tracked), "scannedTextFiles": scanned, | ||
| "workflows": len(workflows), "scripts": len(scripts)}, | ||
| "workflows": [ | ||
| {"path": p, "events": trigger_map.get(p, []), | ||
| "referencedScripts": sorted(s for s in scripts if p in refs[s])} | ||
| for p in workflows], | ||
| "scripts": [ | ||
| {"path": p, "workflowReferences": sorted(x for x in refs[p] if x in workflows), | ||
| "otherTrackedReferences": sorted(x for x in refs[p] if x not in workflows)} | ||
| for p in scripts], | ||
| "requiresManualConsumerReview": sorted(p for p in scripts if not refs[p]), | ||
| "interpretation": "No reference does NOT mean unused. Verify manual invocation, dispatch, external consumers, archived release evidence and dynamic calls before retirement." | ||
| } | ||
| return result | ||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) | ||
| parser.add_argument("--output", type=Path) | ||
| args = parser.parse_args() | ||
| report = json.dumps(inventory(args.root.resolve()), indent=2, ensure_ascii=False) + "\n" | ||
| if args.output: | ||
| args.output.parent.mkdir(parents=True, exist_ok=True) | ||
| args.output.write_text(report, encoding="utf-8") | ||
| else: | ||
| print(report, end="") | ||
| return 0 | ||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| #!/usr/bin/env python3 | ||
| """Offline tests for the read-only maintenance consumer inventory.""" | ||
| import importlib.util | ||
| import json | ||
| import subprocess | ||
| import tempfile | ||
| import unittest | ||
| from pathlib import Path | ||
|
|
||
| SOURCE = Path(__file__).with_name("audit-maintenance-dependencies.py") | ||
| spec = importlib.util.spec_from_file_location("arsas_inventory", SOURCE) | ||
| assert spec and spec.loader | ||
| module = importlib.util.module_from_spec(spec) | ||
| spec.loader.exec_module(module) | ||
|
|
||
| class InventoryTests(unittest.TestCase): | ||
| def setUp(self): | ||
| self.tmp = tempfile.TemporaryDirectory() | ||
| self.root = Path(self.tmp.name) | ||
| subprocess.run(["git", "-C", str(self.root), "init", "-q"], check=True) | ||
|
|
||
| def tearDown(self): | ||
| self.tmp.cleanup() | ||
|
|
||
| def write(self, path, body): | ||
| dest = self.root / path | ||
| dest.parent.mkdir(parents=True, exist_ok=True) | ||
| dest.write_text(body, encoding="utf-8") | ||
|
|
||
| def commit(self): | ||
| subprocess.run(["git", "-C", str(self.root), "add", "-A"], check=True) | ||
| subprocess.run(["git", "-C", str(self.root), "-c", "user.email=test@example.invalid", | ||
| "-c", "user.name=Inventory Test", "commit", "-qm", "synthetic fixture"], check=True) | ||
|
|
||
| def test_workflow_manual_and_review_candidate_are_separate(self): | ||
| self.write("scripts/build-sample.py", 'print("sample")\n') | ||
| self.write("scripts/no-reference.ps1", 'Write-Host "sample"\n') | ||
| self.write(".github/workflows/check.yml", | ||
| "name: Check\non:\n push:\n workflow_dispatch:\njobs:\n t:\n steps:\n - run: python scripts/build-sample.py\n") | ||
| self.write("docs/howto.md", "Manually run build-sample.py.\n") | ||
| self.write("docs/misleading.md", "Not build-sample.py.old or xbuild-sample.py\n") | ||
| self.commit() | ||
| data = module.inventory(self.root) | ||
| self.assertEqual(data["counts"]["workflows"], 1) | ||
| self.assertEqual(data["counts"]["scripts"], 2) | ||
| self.assertEqual(data["workflows"][0]["events"], ["push", "workflow_dispatch"]) | ||
| self.assertEqual(data["workflows"][0]["referencedScripts"], ["scripts/build-sample.py"]) | ||
| lookup = {x["path"]: x for x in data["scripts"]} | ||
| self.assertEqual(lookup["scripts/build-sample.py"]["workflowReferences"], | ||
| [".github/workflows/check.yml"]) | ||
| self.assertEqual(lookup["scripts/build-sample.py"]["otherTrackedReferences"], | ||
| ["docs/howto.md"]) | ||
| self.assertEqual(data["requiresManualConsumerReview"], ["scripts/no-reference.ps1"]) | ||
| self.assertIn("NOT mean unused", data["interpretation"]) | ||
| self.assertEqual(json.dumps(data, sort_keys=True), | ||
| json.dumps(module.inventory(self.root), sort_keys=True)) | ||
|
|
||
| def test_windows_path_and_inline_yaml_events(self): | ||
| self.write("scripts/run-check.ps1", 'Write-Host "x"\n') | ||
| self.write(".github/workflows/check.yml", | ||
| "on: [push, pull_request]\njobs:\n t:\n steps:\n - run: .\\scripts\\run-check.ps1\n") | ||
| self.commit() | ||
| data = module.inventory(self.root) | ||
| self.assertEqual(data["workflows"][0]["events"], ["pull_request", "push"]) | ||
| self.assertEqual(data["workflows"][0]["referencedScripts"], ["scripts/run-check.ps1"]) | ||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a workflow uses valid scalar, quoted, flow-map, or non-two-space-indented YAML such as
on: push,"on":, or a four-space-indented trigger, these regexes return an empty event list. The resulting inventory therefore silently omits real triggers while claiming to list top-level trigger types; handle the valid YAML forms or reject unsupported syntax rather than recording incomplete data.AGENTS.md reference: AGENTS.md:L58-L66
Useful? React with 👍 / 👎.