Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
34 changes: 34 additions & 0 deletions docs/audits/WORKFLOW_SCRIPT_CONSUMER_INVENTORY.md
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.
105 changes: 105 additions & 0 deletions scripts/audit-maintenance-dependencies.py
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)
Comment on lines +32 to +35

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 Parse all valid workflow trigger forms

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 👍 / 👎.

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

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 Bind scanned content to the reported source commit

When the documented command is run from a checkout containing staged or unstaged tracked edits, this reads the worktree files while sourceCommit is still populated from HEAD. The JSON is then attributed to a commit whose blobs do not match the report, making cross-candidate comparisons and provenance unreliable; either read files from the advertised Git tree or reject a dirty index/worktree.

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())
68 changes: 68 additions & 0 deletions scripts/test-maintenance-dependencies.py
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()
Loading