From 20dfacb7de28e6946b08f733ca39616be81d6ecd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 06:20:28 +0700 Subject: [PATCH] chore(maintenance): inventory workflow and script consumers reproducibly --- .github/workflows/build.yml | 20 ++++ .../WORKFLOW_SCRIPT_CONSUMER_INVENTORY.md | 34 ++++++ scripts/audit-maintenance-dependencies.py | 105 ++++++++++++++++++ scripts/test-maintenance-dependencies.py | 68 ++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 docs/audits/WORKFLOW_SCRIPT_CONSUMER_INVENTORY.md create mode 100644 scripts/audit-maintenance-dependencies.py create mode 100644 scripts/test-maintenance-dependencies.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7e68075c7..d11d32785 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,26 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\test-source-clean-guard.ps1 + - name: Set up Python for read-only maintenance inventory + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Test and capture workflow-script consumer inventory + shell: powershell + run: | + python .\ArIED61850Tester\scripts\test-maintenance-dependencies.py + if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory tests failed." } + python .\ArIED61850Tester\scripts\audit-maintenance-dependencies.py --root .\ArIED61850Tester --output "$env:RUNNER_TEMP\arsas-maintenance-dependencies.json" + if ($LASTEXITCODE -ne 0) { throw "Maintenance inventory generation failed." } + + - name: Upload maintenance dependency inventory + uses: actions/upload-artifact@v7 + with: + name: ARSAS-maintenance-dependency-inventory + path: ${{ runner.temp }}/arsas-maintenance-dependencies.json + if-no-files-found: error + - name: Verify premium UX, GOOSE, SMV, SAS and release invariants shell: powershell run: | diff --git a/docs/audits/WORKFLOW_SCRIPT_CONSUMER_INVENTORY.md b/docs/audits/WORKFLOW_SCRIPT_CONSUMER_INVENTORY.md new file mode 100644 index 000000000..dba2cab04 --- /dev/null +++ b/docs/audits/WORKFLOW_SCRIPT_CONSUMER_INVENTORY.md @@ -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. diff --git a/scripts/audit-maintenance-dependencies.py b/scripts/audit-maintenance-dependencies.py new file mode 100644 index 000000000..f93594637 --- /dev/null +++ b/scripts/audit-maintenance-dependencies.py @@ -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"(? 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()) diff --git a/scripts/test-maintenance-dependencies.py b/scripts/test-maintenance-dependencies.py new file mode 100644 index 000000000..1b580b7cf --- /dev/null +++ b/scripts/test-maintenance-dependencies.py @@ -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()