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
128 changes: 113 additions & 15 deletions .github/scripts/node-repo-project-scope.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
#!/usr/bin/env python3
"""Select compiled host builds and suites using the caller's project graph and input policy.

Policy is a nonempty list of {project, kind: build|test, inputs: [repo-relative prefixes]}.
Policy is a nonempty list of {project, kind: build|test, inputs: [repo-relative prefixes or globs]}.
Inputs name runtime source scans that ProjectReference cannot express. Declared linked content
is read from every project in the closure. Unknown paths and unresolved scope run everything.
This selects validation only; it never decides what gets published.

Two precisions (2026-10-03, MeshWeaver.Plugins — "should only run test of delta plugin not all";
measured on Plugins#2734, which changed only a vendored Monaco bundle under
src/MeshWeaver.Blazor/wwwroot/ and owed 14 portal-host suites, four 22-minute Blazor legs among them):

* An input or a linked include holding `*` or `?` is a GLOB, matched whole (`input_covers`):
`**/` spans zero or more directories, `**` anything, `*` and `?` stay inside one segment. A
linked `../X/**/*.razor` reads Razor markup, not X's `wwwroot/`; an input `src/**/*.cs` is a
census of C# files, not of every vendored asset. A prefix stays a prefix. The caller's
scripts/ci-tests.py reads both with the same grammar, so the reconcile agrees by construction.
* A STATIC WEB ASSET (`<project>/wwwroot/...`) of a project the caller declares IMAGE-SHIPPED
(its project-closure.py `platform_shipped`, i.e. src/platform-shipped.txt) is read by no
compiler and carried by no module bundle, so it does not reach a project merely by being in
that project's compiled closure: only the owner itself, its sibling `<owner>.Test`, and an
entry that LINKS it or DECLARES it as an input. A caller without that helper keeps the plain
closure rule (the loud direction).
"""
import argparse
import importlib.util
Expand All @@ -27,6 +43,46 @@ def under(path, prefix):
return prefix == "." or path == prefix.rstrip("/") or path.startswith(prefix.rstrip("/") + "/")


def _glob_re(pattern):
out, i = [], 0
while i < len(pattern):
if pattern.startswith("**/", i):
out.append("(?:.*/)?"); i += 3
elif pattern.startswith("**", i):
out.append(".*"); i += 2
elif pattern[i] == "*":
out.append("[^/]*"); i += 1
elif pattern[i] == "?":
out.append("[^/]"); i += 1
else:
out.append(re.escape(pattern[i])); i += 1
return re.compile("".join(out))


def input_covers(path, pattern):
"""A declared input or linked include — a prefix, `.`, or a glob — covers this path."""
if "*" in pattern or "?" in pattern:
return _glob_re(pattern).fullmatch(path) is not None
return under(path, pattern)


def linked_pattern(root, project, raw):
"""One `../` include as a repo-relative path or glob, its wildcard part kept; None if not a link."""
raw = raw.strip().replace("\\", "/")
if not raw or "$(" in raw or "%(" in raw or not raw.startswith("../"):
return None
wild = re.search(r"[*?]", raw)
cut = len(raw) if wild is None else raw.rfind("/", 0, wild.start()) + 1
try:
base = (project.parent / raw[:cut]).resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return None # outside this checkout: the pinned platform is its input
rest = raw[cut:]
if not rest:
return base
return (base.rstrip("/") + "/" if base not in ("", ".") else "") + rest


def select(root, policy, files):
if not isinstance(policy, list) or not policy:
raise ValueError("project policy must be a nonempty list")
Expand Down Expand Up @@ -54,6 +110,11 @@ def answer(entries, reason):
graph = projects.graph_of(root)
except (OSError, AttributeError, ValueError, ImportError, SyntaxError):
return answer(policy, "project graph unavailable — full validation")
shipped_of = getattr(projects, "platform_shipped", None)
try:
shipped = set(shipped_of(root)) if callable(shipped_of) else set()
except (OSError, ValueError):
shipped = set()
packages = scope.node_packages(root)
noop_dirs, _ = scope.resolve_noop_dirs(root)
if noop_dirs is None:
Expand All @@ -78,25 +139,38 @@ def answer(entries, reason):
elif path.split("/", 1)[0] not in packages:
return answer(policy, f"shared or unknown input {path} — full validation")
relevant.append(path)
# A static web asset of an image-shipped project reaches no referencing project's closure.
asset_owner = {}
for path in relevant:
if path.startswith("src/"):
owner = max((p for p in graph if under(path, p)), key=len, default=None)
if owner and owner.rsplit("/", 1)[-1] in shipped and path.startswith(owner + "/wwwroot/"):
asset_owner[path] = owner
chosen = []
for entry in policy:
closure = projects.forward(projects.entry_dir(entry["project"]), graph)
prefixes = list(closure) + entry.get("inputs", [])
entry_dir = projects.entry_dir(entry["project"])
closure = projects.forward(entry_dir, graph)
reads = list(entry.get("inputs", []))
# Linked files in node packages are dependencies too (e.g. Cornerstone's test data).
for directory in closure:
for project in (root / directory).glob("*.csproj"):
source = project.read_text()
for raw in re.findall(r'<(?:Compile|Content|EmbeddedResource|None)\s[^>]*Include=["\']([^"\']+)', source):
raw = raw.replace("\\", "/")
if "$(" in raw or not raw.startswith("../"):
continue
literal = re.split(r"[*?]", raw, maxsplit=1)[0]
try:
prefix = (project.parent / literal).resolve().relative_to(root.resolve()).as_posix()
except ValueError:
continue # outside this checkout: the pinned platform is its input
prefixes.append(prefix)
if any(under(path, prefix) for path in relevant for prefix in prefixes):
for attr in re.findall(r'<(?:Compile|Content|EmbeddedResource|None)\s[^>]*Include=["\']([^"\']+)', source):
for raw in attr.split(";"):
pattern = linked_pattern(root, project, raw)
if pattern is not None:
reads.append(pattern)

def reaches(path):
owner = asset_owner.get(path)
if owner is not None:
if entry_dir in (owner, owner + ".Test"):
return True
elif any(under(path, d) for d in closure):
return True
return any(input_covers(path, r) for r in reads)

if any(reaches(path) for path in relevant):
chosen.append(entry)
return answer(chosen, "compiled project closure plus declared runtime/linked inputs")

Expand All @@ -107,6 +181,7 @@ def self_test():
root = Path(tmp)
scope = load(Path(__file__).with_name("node-repo-scope.py"), "scope_fixture")
scope._fixture(root)
_ENTRY_SOURCE = (root / scope._ENTRY_A["project"]).read_text()
# A tiny real project graph; the production graph is owned by the caller.
(root / scope.PROJECTS).write_text('''from pathlib import Path
def graph_of(root):
Expand All @@ -129,6 +204,29 @@ def forward(seed, graph): return {seed} | graph.get(seed, set())
assert select(root, policy, ["Beta/manifest.lock"])["count"] == 0
assert select(root, policy, ["Beta/manifest.lock", "Beta/Lesson.md"])["test"] == [b["project"]]
assert select(root, policy, None)["count"] == 2
# GLOB inputs: a census of one file kind is not a read of every file under its root.
g = {**b, "inputs": ["src/**/*.cs"]}
assert select(root, [a, g], ["src/Acme.Shared/Thing.cs"])["test"] == [b["project"]]
assert select(root, [a, g], ["src/Acme.Shared/wwwroot/x.js"])["test"] == []
assert input_covers("src/C.cs", "src/**/*.cs") and not input_covers("src/A/b.js", "src/**/*.cs")
assert input_covers("Store/Core/Source/X.cs", "Store/*/Source/*.cs") and not input_covers("Store/Core/Source/D/X.cs", "Store/*/Source/*.cs")
# A STATIC ASSET of an IMAGE-SHIPPED project (Plugins#2734): Alpha compiles Acme.Shared, but
# no compiler reads Shared's wwwroot/ and no bundle carries it — unless the caller does not
# declare Shared image-shipped, in which case the closure rule stands.
assert select(root, policy, ["src/Acme.Shared/wwwroot/lib/monaco.js"])["build"] == [a["project"]]
(root / scope.PROJECTS).write_text((root / scope.PROJECTS).read_text()
+ "def platform_shipped(root): return {'Acme.Shared'}\n")
assert select(root, policy, ["src/Acme.Shared/wwwroot/lib/monaco.js"])["count"] == 0
assert select(root, policy, ["src/Acme.Shared/wwwroot/lib/monaco.js", "src/Acme.Shared/Thing.cs"])["build"] == [a["project"]]
assert select(root, policy, ["src/Acme.Shared/wwwrootish/x.js"])["build"] == [a["project"]]
linker = {**a, "inputs": []}
(root / a["project"]).write_text('<Project><Content Include="../Acme.Shared/wwwroot/lib/**" /></Project>')
assert select(root, [linker, b], ["src/Acme.Shared/wwwroot/lib/monaco.js"])["build"] == [a["project"]]
# A GLOB link reads what it names — `**/*.razor` is not the asset folder; `;` separates links.
(root / a["project"]).write_text('<Project><Content Include="../Acme.Shared/**/*.razor;../Acme.Shared/**/*.razor.cs" /></Project>')
assert select(root, [linker, b], ["src/Acme.Shared/wwwroot/lib/monaco.js"])["count"] == 0
assert select(root, [linker, b], ["src/Acme.Shared/View.razor.cs"])["build"] == [a["project"]]
(root / a["project"]).write_text(_ENTRY_SOURCE)
project = root / a["project"]
project.write_text('<Project><Content Include="../../Alpha/**" /></Project>')
assert select(root, policy, ["Alpha/Lesson.md"])["build"] == [a["project"]]
Expand All @@ -149,7 +247,7 @@ def forward(seed, graph): return {seed} | graph.get(seed, set())
assert fallback.select(root, policy, ["Alpha/Lesson.md"])["count"] == 2
(isolated / "node-repo-scope.py").write_text("this is invalid Python !")
assert fallback.select(root, policy, ["Alpha/Lesson.md"])["count"] == 2
print("compiled project scope: 18 assertions passed")
print("compiled project scope: 29 assertions passed")


def main():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,25 @@ is an input even when it lives outside `src/`. Unknown paths, absent graphs and
full validation; malformed or missing inventory entries fail. Selection and execution must be
reconciled by the caller's stable required check.

Two precisions keep the selection to what a change can actually reach (2026-10-03, measured on
MeshWeaver.Plugins#2734, which changed only a vendored Monaco bundle under
`src/MeshWeaver.Blazor/wwwroot/` and owed 14 portal-host suites, four 22-minute Blazor legs among them):

- **An input or a linked include may be a glob.** `**/` spans zero or more directories, `**` anything,
`*` and `?` stay inside one path segment; a pattern without a wildcard is the prefix it always was. A
linked `../X/**/*.razor` reads Razor markup, not `X/wwwroot/`; an input `src/**/*.cs` is a census of
C# files, not of every vendored asset. A linked `Include` holding several `;`-separated patterns is
read as each of them.
- **A static web asset of an image-shipped project reaches no referencing project.** A file under
`<project>/wwwroot/` is read by no compiler; when the caller declares the project image-shipped (its
`project-closure.py` `platform_shipped`, from `src/platform-shipped.txt`) no module bundle carries it
either. Such a path selects only the owner, its sibling `<owner>.Test`, and an entry that links it or
declares it as an input. A caller without that helper keeps the plain closure rule.

The caller's own declared selector (MeshWeaver.Plugins `scripts/ci-tests.py`) applies the same two rules
with the same grammar, so the reconcile agrees by construction. The per-PR analysis is the caller's
page `Hosting/CiAffectedClosure`.

The module lane accepts an optional `publication-base` on main pushes. Obtain it only from
`node-repo-publication-base.py`, naming the caller's complete publishing workflow. It reads a
successful completed **main push**, verifies that commit is an ancestor, and otherwise supplies
Expand Down