Merged
ci: 自动发现增量测试并移除算子映射表 #48
wangliweiAdd on 19 days ago
ci: 自动发现增量测试并移除算子映射表 #48
Merged
wangliweiAdd on 19 days ago
Total: 6 files changed+812-261
@@ -1,8 +1,10 @@
1# Files whose changes require running all L0 functional tests.1# Files whose changes require running all L0 functional tests.
2#2#
3-# Format: one repository-relative Bash glob per line. Blank lines and3+# Format: one repository-relative glob per line (Python fnmatch). Blank lines and
4# full-line comments are ignored. Matching is based on pr_filelist.txt, so4# full-line comments are ignored. Matching is based on pr_filelist.txt, so
5# added, modified, and deleted paths can all trigger the full test scope.5# added, modified, and deleted paths can all trigger the full test scope.
6+# .gitcode/ is excluded by default so CI-only adjustments can skip operator tests.
7+# Add an explicit rule here if a CI path should require the full test scope.
6 8 
7src/cann_ops_tilelang/config.py9src/cann_ops_tilelang/config.py
8src/cann_ops_tilelang/utils.py10src/cann_ops_tilelang/utils.py
@@ -10,3 +12,7 @@ src/cann_ops_tilelang/utils.py
10tests/conftest.py12tests/conftest.py
11tests/pytest_*_plugin.py13tests/pytest_*_plugin.py
12tests/testing/*14tests/testing/*
15+ 
16+pyproject.toml
17+requirements*.txt
18+scripts/*.sh
@@ -1,82 +0,0 @@
1-# Operator-level incremental pytest mapping.
2-# Format: <source-pattern> <test-path> [<test-path> ...], separated by whitespace.
3-# All paths are relative to the repository root; paths cannot contain spaces.
4-# Source patterns use Bash globs (*, ?, [...]); test paths are explicit files.
5-# Blank lines and full-line comments are ignored. The first matching row wins.
6-# Put specific patterns before broader ones. Use separate rows for source aliases.
7-# Missing test files are ignored by ut.sh (e.g. tests awaiting migration).
8-# Only operator sources are mapped; modeling integration paths are excluded.
9- 
10-# Engram
11-src/cann_ops_tilelang/engram/engram_fused_weight*.py tests/engram/test_engram_fused_weight.py
12-src/cann_ops_tilelang/engram/engram_gate*.py tests/engram/test_engram_gate_fwd.py tests/engram/test_engram_gate_bwd.py
13-src/cann_ops_tilelang/engram/engram_grad_w_reduce*.py tests/engram/test_engram_grad_w_reduce.py
14-src/cann_ops_tilelang/engram/engram_hash*.py tests/engram/test_engram_hash.py
15-src/cann_ops_tilelang/engram/engram_sinkhorn*.py tests/engram/test_engram_sinkhorn.py
16- 
17-# MHC
18-src/cann_ops_tilelang/mhc/expand*.py tests/mhc/test_expand.py
19-src/cann_ops_tilelang/mhc/head_compute_mix*.py tests/mhc/test_head_compute_mix.py
20-src/cann_ops_tilelang/mhc/multilayer_recompute*.py tests/mhc/test_multilayer_recompute.py
21-src/cann_ops_tilelang/mhc/norm_fn*.py tests/mhc/test_norm_fn.py
22-src/cann_ops_tilelang/mhc/post*.py tests/mhc/test_post.py
23-src/cann_ops_tilelang/mhc/pre_apply_mix*.py tests/mhc/test_pre_apply_mix.py
24-src/cann_ops_tilelang/mhc/pre_big_fuse*.py tests/mhc/test_pre_big_fuse.py
25-src/cann_ops_tilelang/mhc/pre_split_mixes*.py tests/mhc/test_pre_split_mixes.py
26-src/cann_ops_tilelang/mhc/sinkhorn*.py tests/mhc/test_sinkhorn.py
27- 
28-# MoE
29-src/cann_ops_tilelang/moe/moe_topk_gate*.py tests/moe/test_moe_topk_gate.py
30-src/cann_ops_tilelang/moe/normalize_weight*.py tests/moe/test_normalize_weight.py
31-src/cann_ops_tilelang/moe/topk_gate*.py tests/moe/test_topk_gate.py
32- 
33-# Attention
34-src/cann_ops_tilelang/attention/_simd.py tests/attention/test_cache.py
35-src/cann_ops_tilelang/attention/cache*.py tests/attention/test_cache.py
36-src/cann_ops_tilelang/attention/flash_mla_utils*.py tests/attention/test_flash_mla_utils.py
37-src/cann_ops_tilelang/attention/get_kv_index*.py tests/attention/test_get_kv_index.py
38-src/cann_ops_tilelang/attention/kv_index_cat*.py tests/attention/test_kv_index_cat.py
39-src/cann_ops_tilelang/attention/prepare_cp_mirror*.py tests/attention/test_prepare_cp_mirror.py
40-src/cann_ops_tilelang/attention/prepare_llama_decode_metadata*.py tests/attention/test_prepare_llama_decode_metadata.py
41-src/cann_ops_tilelang/attention/prepare_v33_decode_metadata*.py tests/attention/test_prepare_v33_decode_metadata.py
42-src/cann_ops_tilelang/attention/rope*.py tests/attention/test_rope.py
43-src/cann_ops_tilelang/attention/state_cache*.py tests/attention/test_state_cache.py
44- 
45-# Multimodal
46-src/cann_ops_tilelang/multimodal/dsocr*.py tests/multimodal/test_dsocr.py
47-src/cann_ops_tilelang/multimodal/image_embed_ops*.py tests/multimodal/test_image_embed_ops.py
48- 
49-# Normalization
50-src/cann_ops_tilelang/normalization/norm*.py tests/normalization/test_norm.py
51- 
52-# Quant: specific names must precede overlapping generic names.
53-src/cann_ops_tilelang/quant/per_block_cast_lossless*.py tests/quant/test_per_block_cast_lossless.py
54-src/cann_ops_tilelang/quant/per_block_cast*.py tests/quant/test_per_block_cast.py
55-src/cann_ops_tilelang/quant/per_channel_cast_with_psum*.py tests/quant/test_per_channel_cast_with_psum.py
56-src/cann_ops_tilelang/quant/per_channel_cast*.py tests/quant/test_per_channel_cast.py
57-src/cann_ops_tilelang/quant/per_token_cast_and_cast_back*.py tests/quant/test_per_token_cast_and_cast_back.py
58-src/cann_ops_tilelang/quant/per_token_cast*.py tests/quant/test_per_token_cast.py tests/attention/test_cache.py
59-src/cann_ops_tilelang/quant/cast_back*.py tests/quant/test_cast_back.py
60-src/cann_ops_tilelang/quant/swiglu_backward*.py tests/quant/test_swiglu_backward.py
61-src/cann_ops_tilelang/quant/swiglu_forward*.py tests/quant/test_swiglu_forward.py
62- 
63-# Sampling
64-src/cann_ops_tilelang/sampling/fused_sample*.py tests/sampling/test_fused_sample.py
65-src/cann_ops_tilelang/sampling/prompt_logprobs*.py tests/sampling/test_prompt_logprobs.py
66-src/cann_ops_tilelang/sampling/random_sampling*.py tests/sampling/test_random_sampling.py tests/sampling/test_fused_sample.py
67-src/cann_ops_tilelang/sampling/sampler*.py tests/sampling/test_sampler.py
68-src/cann_ops_tilelang/sampling/schedule_mtp_varlen*.py tests/sampling/test_schedule_mtp_varlen.py
69-src/cann_ops_tilelang/sampling/softmax*.py tests/sampling/test_softmax.py tests/sampling/test_fused_sample.py
70-src/cann_ops_tilelang/sampling/structure_output*.py tests/sampling/test_structure_output.py
71-src/cann_ops_tilelang/sampling/sum_smaller_probs*.py tests/sampling/test_sum_smaller_probs.py
72- 
73-# Tensor: specific gather variants must precede the generic gather pattern.
74-src/cann_ops_tilelang/tensor/compute_seqlens_k_zos*.py tests/tensor/test_compute_seqlens_k_zos.py
75-src/cann_ops_tilelang/tensor/gather_engram_tokens_zos*.py tests/tensor/test_gather_engram_tokens_zos.py
76-src/cann_ops_tilelang/tensor/gather_input_ids_zos*.py tests/tensor/test_gather_input_ids_zos.py
77-src/cann_ops_tilelang/tensor/gather*.py tests/tensor/test_gather.py
78-src/cann_ops_tilelang/tensor/unbind*.py tests/tensor/test_unbind.py
79- 
80-# Transpose
81-src/cann_ops_tilelang/transpose/batched_transpose*.py tests/transpose/test_transpose.py
82-src/cann_ops_tilelang/transpose/transpose*.py tests/transpose/test_transpose.py
@@ -0,0 +1,380 @@
1+#!/usr/bin/env python3
2+# MIT License
3+#
4+# Copyright (c) 2026 ops-tilelang contributors
5+#
6+# Permission is hereby granted, free of charge, to any person obtaining a copy
7+# of this software and associated documentation files (the "Software"), to deal
8+# in the Software without restriction, including without limitation the rights
9+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+# copies of the Software, and to permit persons to whom the Software is
11+# furnished to do so, subject to the following conditions:
12+#
13+# The above copyright notice and this permission notice shall be included in all
14+# copies or substantial portions of the Software.
15+#
16+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+# SOFTWARE.
23+ 
24+"""Select affected pytest files without importing the operator package."""
25+ 
26+from __future__ import annotations
27+ 
28+import argparse
29+import ast
30+import logging
31+import sys
32+from collections import defaultdict
33+from dataclasses import dataclass
34+from fnmatch import fnmatchcase
35+from pathlib import Path, PurePosixPath
36+ 
37+LOGGER = logging.getLogger(__name__)
38+ 
39+ 
40+@dataclass(frozen=True)
41+class Selection:
42+ mode: str
43+ targets: tuple[str, ...]
44+ reasons: tuple[str, ...]
45+ 
46+ 
47+@dataclass(frozen=True)
48+class Reference:
49+ module: str
50+ names: tuple[str, ...] = ()
51+ 
52+ 
53+def _internal(module: str) -> bool:
54+ return any(module == root or module.startswith(root + '.') for root in ('cann_ops_tilelang', 'tests'))
55+ 
56+ 
57+def _test_file(path: str) -> bool:
58+ name = PurePosixPath(path).name
59+ return path.startswith('tests/') and (fnmatchcase(name, 'test_*.py') or fnmatchcase(name, '*_test.py'))
60+ 
61+ 
62+def _document(path: str) -> bool:
63+ return PurePosixPath(path).suffix.lower() in {'.md', '.rst'} or PurePosixPath(path).name in {
64+ 'LICENSE',
65+ 'NOTICE',
66+ 'COPYING',
67+ }
68+ 
69+ 
70+def _normalize(path: str) -> str:
71+ path = path.strip().replace('\\', '/')
72+ parsed = PurePosixPath(path)
73+ has_invalid_component = '..' in parsed.parts or ':' in path
74+ if not path or parsed.is_absolute() or has_invalid_component:
75+ raise ValueError(f'invalid repository-relative changed path: {path!r}')
76+ return str(parsed)
77+ 
78+ 
79+class DependencyGraph:
80+ """Track file dependencies, resolving package exports to their definitions.
81+ 
82+ Package initializers are not expanded for every import: importing ops.foo
83+ depends on foo's definition, not every symbol re-exported by ops. Changes
84+ to initializers themselves always require the full suite.
85+ """
86+ 
87+ def __init__(self, root: Path):
88+ self.modules: dict[str, str] = {}
89+ self.packages: set[str] = set()
90+ self.trees: dict[str, ast.Module] = {}
91+ self.exports: dict[str, dict[str, Reference]] = defaultdict(dict)
92+ self.edges: dict[str, set[str]] = defaultdict(set)
93+ self.problems: list[str] = []
94+ for directory in ('src/cann_ops_tilelang', 'tests'):
95+ for file in sorted((root / directory).rglob('*.py')):
96+ path = file.relative_to(root).as_posix()
97+ parts = list(PurePosixPath(path).with_suffix('').parts)
98+ if parts[0] == 'src':
99+ parts.pop(0)
100+ is_package = parts[-1] == '__init__'
101+ if is_package:
102+ parts.pop()
103+ module = '.'.join(parts)
104+ if module in self.modules:
105+ self.problems.append(f'ambiguous module {module}')
106+ self.modules[module] = path
107+ for length in range(1, len(parts) + int(is_package)):
108+ self.packages.add('.'.join(parts[:length]))
109+ try:
110+ self.trees[module] = ast.parse(file.read_text(encoding='utf-8-sig'), filename=path)
111+ except (OSError, UnicodeError, SyntaxError) as error:
112+ self.problems.append(f'cannot parse {path}: {error}')
113+ # Build all export tables before resolving any import chain.
114+ for module, tree in self.trees.items():
115+ if module in self.packages:
116+ for node in tree.body:
117+ for name, reference in self._imports(module, node):
118+ self.exports[module][name] = reference
119+ for module, tree in self.trees.items():
120+ self._scan(module, tree)
121+ 
122+ def affected_files(self, changed: str) -> set[str]:
123+ reverse: dict[str, set[str]] = defaultdict(set)
124+ for consumer, dependencies in self.edges.items():
125+ for dependency in dependencies:
126+ reverse[dependency].add(consumer)
127+ visited, pending = set(), [changed]
128+ while pending:
129+ path = pending.pop()
130+ if path not in visited:
131+ visited.add(path)
132+ pending.extend(reverse[path] - visited)
133+ return visited
134+ 
135+ def _base(self, module: str, node: ast.ImportFrom) -> str:
136+ if not node.level:
137+ return node.module or ''
138+ package = module if module in self.packages else module.rpartition('.')[0]
139+ parts = package.split('.')
140+ if node.level > len(parts):
141+ self.problems.append(f'invalid relative import in {self.modules[module]}:{node.lineno}')
142+ return ''
143+ prefix = parts[: len(parts) - node.level + 1]
144+ return '.'.join(prefix + ([node.module] if node.module else []))
145+ 
146+ def _imports(self, module: str, node: ast.AST) -> list[tuple[str, Reference]]:
147+ if isinstance(node, ast.Import):
148+ return [
149+ (
150+ item.asname or item.name.split('.')[0],
151+ Reference(item.name if item.asname else item.name.split('.')[0]),
152+ )
153+ for item in node.names
154+ if _internal(item.name)
155+ ]
156+ if isinstance(node, ast.ImportFrom):
157+ base = self._base(module, node)
158+ if _internal(base):
159+ return [(item.asname or item.name, Reference(base, (item.name,))) for item in node.names]
160+ return []
161+ 
162+ def _resolve(self, reference: Reference, seen: frozenset[Reference] = frozenset()) -> str | None:
163+ module, names = reference.module, reference.names
164+ if reference in seen:
165+ raise ValueError(f'cyclic package export: {module}.{".".join(names)}')
166+ if module not in self.packages:
167+ if module in self.modules:
168+ return self.modules[module]
169+ raise ValueError(f'unresolved internal module: {module}')
170+ if not names:
171+ return None # A namespace, not a dependency on every exported symbol.
172+ if names[0] == '*':
173+ raise ValueError(f'star import from internal package: {module}')
174+ exported = self.exports[module].get(names[0])
175+ child = module + '.' + names[0]
176+ if exported and exported != Reference(module, (names[0],)):
177+ return self._resolve(Reference(exported.module, exported.names + names[1:]), seen | {reference})
178+ if child in self.modules or child in self.packages:
179+ return self._resolve(Reference(child, names[1:]), seen | {reference})
180+ raise ValueError(f'unresolved internal export: {child}')
181+ 
182+ def _scan(self, module: str, tree: ast.Module) -> None:
183+ path = self.modules[module]
184+ bindings: dict[str, set[Reference]] = defaultdict(set)
185+ loader_names = {'__import__'}
186+ importlib_names = {'importlib'}
187+ nodes = list(ast.walk(tree))
188+ parents = {child: parent for parent in nodes for child in ast.iter_child_nodes(parent)}
189+ 
190+ def depend(reference: Reference, node: ast.AST) -> str | None:
191+ try:
192+ dependency = self._resolve(reference)
193+ if dependency:
194+ self.edges[path].add(dependency)
195+ return dependency
196+ except ValueError as error:
197+ self.problems.append(f'{path}:{getattr(node, "lineno", 0)}: {error}')
198+ return None
199+ 
200+ for node in nodes:
201+ for name, reference in self._imports(module, node):
202+ bindings[name].add(reference)
203+ depend(reference, node)
204+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
205+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
206+ if any(isinstance(target, ast.Name) and target.id == 'pytest_plugins' for target in targets):
207+ try:
208+ plugins = ast.literal_eval(node.value)
209+ plugins = [plugins] if isinstance(plugins, str) else plugins
210+ if not isinstance(plugins, (list, tuple)) or not all(isinstance(item, str) for item in plugins):
211+ raise ValueError('expected constant plugin names')
212+ for plugin in plugins:
213+ if _internal(plugin) and depend(Reference(plugin), node) is None:
214+ if plugin in self.modules:
215+ self.edges[path].add(self.modules[plugin])
216+ else:
217+ self.problems.append(f'{path}:{node.lineno}: unresolved pytest plugin {plugin}')
218+ except (ValueError, TypeError):
219+ self.problems.append(f'{path}:{node.lineno}: dynamic pytest_plugins declaration')
220+ if isinstance(node, ast.Import):
221+ for item in node.names:
222+ if _internal(item.name):
223+ depend(Reference(item.name), node)
224+ if item.name == 'importlib':
225+ importlib_names.add(item.asname or item.name)
226+ elif isinstance(node, ast.ImportFrom) and node.module == 'importlib':
227+ loader_names.update(item.asname or item.name for item in node.names if item.name == 'import_module')
228+ 
229+ def references(node: ast.AST) -> list[Reference]:
230+ attributes = []
231+ while isinstance(node, ast.Attribute):
232+ attributes.append(node.attr)
233+ node = node.value
234+ if not isinstance(node, ast.Name):
235+ return []
236+ return [Reference(ref.module, ref.names + tuple(reversed(attributes))) for ref in bindings.get(node.id, ())]
237+ 
238+ for node in nodes:
239+ parent = parents.get(node)
240+ is_direct_call = isinstance(parent, ast.Call) and parent.func is node
241+ is_loader_name = isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in loader_names
242+ if is_loader_name and not is_direct_call:
243+ self.problems.append(f'{path}:{node.lineno}: dynamic import function used as a value')
244+ if isinstance(node, ast.Attribute):
245+ is_import_module = (
246+ node.attr == 'import_module'
247+ and isinstance(node.value, ast.Name)
248+ and node.value.id in importlib_names
249+ )
250+ if is_import_module and not is_direct_call:
251+ self.problems.append(f'{path}:{node.lineno}: dynamic import function used as a value')
252+ if not isinstance(parent, ast.Attribute) or parent.value is not node:
253+ for reference in references(node):
254+ if depend(reference, node) is None:
255+ self.problems.append(f'{path}:{node.lineno}: internal package used dynamically')
256+ elif isinstance(node, ast.Call):
257+ function = node.func
258+ loader = isinstance(function, ast.Name) and function.id in loader_names
259+ loader = loader or (
260+ isinstance(function, ast.Attribute)
261+ and function.attr == 'import_module'
262+ and isinstance(function.value, ast.Name)
263+ and function.value.id in importlib_names
264+ )
265+ if loader:
266+ name = node.args[0].value if node.args and isinstance(node.args[0], ast.Constant) else None
267+ if not isinstance(name, str) or _internal(name) or name.startswith('.'):
268+ self.problems.append(f'{path}:{node.lineno}: dynamic internal import')
269+ if (
270+ isinstance(function, ast.Name)
271+ and function.id in {'getattr', 'hasattr', 'setattr', 'delattr'}
272+ and node.args
273+ ):
274+ refs = references(node.args[0])
275+ attribute = (
276+ node.args[1].value if len(node.args) > 1 and isinstance(node.args[1], ast.Constant) else None
277+ )
278+ for reference in refs:
279+ if isinstance(attribute, str) and function.id in {'getattr', 'hasattr'}:
280+ if depend(Reference(reference.module, reference.names + (attribute,)), node) is None:
281+ self.problems.append(f'{path}:{node.lineno}: internal package used dynamically')
282+ else:
283+ self.problems.append(f'{path}:{node.lineno}: dynamic internal attribute access')
284+ if isinstance(function, ast.Name) and function.id in {'eval', 'exec', 'globals', 'locals'}:
285+ self.problems.append(f'{path}:{node.lineno}: dynamic namespace access')
286+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in bindings:
287+ parent = parents.get(node)
288+ if isinstance(parent, ast.Attribute) and parent.value is node:
289+ continue
290+ is_attribute_access = (
291+ isinstance(parent, ast.Call)
292+ and isinstance(parent.func, ast.Name)
293+ and parent.func.id in {'getattr', 'hasattr', 'setattr', 'delattr'}
294+ )
295+ if is_attribute_access and parent.args and parent.args[0] is node:
296+ continue
297+ for reference in bindings[node.id]:
298+ if depend(reference, node) is None:
299+ self.problems.append(f'{path}:{node.lineno}: internal package used dynamically')
300+ 
301+ 
302+def select_tests(root: Path, changed_paths: list[str], full_patterns: list[str]) -> Selection:
303+ root = Path(root).resolve()
304+ if not root.is_dir():
305+ raise ValueError(f'repository root does not exist: {root}')
306+ if not changed_paths:
307+ raise ValueError('changed-file list is empty')
308+ paths = sorted({_normalize(path) for path in changed_paths})
309+ 
310+ def full(reason: str) -> Selection:
311+ return Selection('full', ('tests',), (reason,))
312+ 
313+ code = []
314+ for path in paths:
315+ for pattern in full_patterns:
316+ if fnmatchcase(path, pattern):
317+ return full(f'{path}: full-test rule {pattern}')
318+ # Keep CI-only adjustments available without forcing operator tests.
319+ # Explicit full-test rules above still take precedence.
320+ if _document(path) or path.startswith('.gitcode/'):
321+ continue
322+ if not path.endswith('.py') or not path.startswith(('src/cann_ops_tilelang/', 'tests/')):
323+ return full(f'{path}: unclassified non-document change')
324+ if not (root / path).is_file():
325+ return full(f'{path}: deleted or unavailable Python file')
326+ if PurePosixPath(path).name == '__init__.py':
327+ return full(f'{path}: package initializer changed')
328+ code.append(path)
329+ if not code:
330+ return Selection('skip', (), ('only documentation or CI files changed',))
331+ 
332+ graph = DependencyGraph(root)
333+ if graph.problems:
334+ return full('dependency analysis is uncertain: ' + graph.problems[0])
335+ targets, reasons = set(), []
336+ for path in code:
337+ affected_files = graph.affected_files(path)
338+ if any(PurePosixPath(consumer).name == 'conftest.py' for consumer in affected_files):
339+ return full(f'{path}: affects implicitly loaded pytest fixtures')
340+ affected = {consumer for consumer in affected_files if _test_file(consumer)}
341+ if not affected:
342+ return full(f'{path}: no affected tests could be established')
343+ targets.update(affected)
344+ reasons.append(f'{path} -> {", ".join(sorted(affected))}')
345+ return Selection('incremental', tuple(sorted(targets)), tuple(reasons))
346+ 
347+ 
348+def main() -> int:
349+ logging.basicConfig(level=logging.INFO, format='%(message)s', stream=sys.stderr)
350+ parser = argparse.ArgumentParser(description=__doc__)
351+ parser.add_argument('--root', type=Path, required=True)
352+ parser.add_argument('--changed-files', type=Path, required=True)
353+ parser.add_argument('--full-test-triggers', type=Path, required=True)
354+ arguments = parser.parse_args()
355+ try:
356+ changed = [
357+ line.strip()
358+ for line in arguments.changed_files.read_text(encoding='utf-8-sig').splitlines()
359+ if line.strip()
360+ ]
361+ patterns = [
362+ line.strip()
363+ for line in arguments.full_test_triggers.read_text(encoding='utf-8-sig').splitlines()
364+ if line.strip() and not line.lstrip().startswith('#')
365+ ]
366+ if not patterns or any(len(pattern.split()) != 1 for pattern in patterns):
367+ raise ValueError('full-test trigger config must contain one nonempty pattern per rule')
368+ result = select_tests(arguments.root, changed, patterns)
369+ except (OSError, ValueError) as error:
370+ LOGGER.error('test selection failed: %s', error)
371+ return 2
372+ for reason in result.reasons:
373+ LOGGER.info('%s', reason)
374+ # stdout is the machine-readable protocol consumed by ut.sh.
375+ sys.stdout.write('\n'.join((result.mode, *result.targets)) + '\n')
376+ return 0
377+ 
378+ 
379+if __name__ == '__main__':
380+ raise SystemExit(main())
@@ -0,0 +1,383 @@
1+# MIT License
2+#
3+# Copyright (c) 2026 ops-tilelang contributors
4+#
5+# Permission is hereby granted, free of charge, to any person obtaining a copy
6+# of this software and associated documentation files (the "Software"), to deal
7+# in the Software without restriction, including without limitation the rights
8+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+# copies of the Software, and to permit persons to whom the Software is
10+# furnished to do so, subject to the following conditions:
11+#
12+# The above copyright notice and this permission notice shall be included in all
13+# copies or substantial portions of the Software.
14+#
15+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+# SOFTWARE.
22+ 
23+"""CPU-only black-box regression tests for incremental test selection.
24+ 
25+Each case builds a tiny source checkout and invokes the selector CLI. No
26+operator module, pytest plugin, accelerator runtime, or kernel is imported.
27+"""
28+ 
29+from __future__ import annotations
30+ 
31+import subprocess
32+import sys
33+import tempfile
34+import unittest
35+from pathlib import Path
36+ 
37+SELECTOR = Path(__file__).resolve().parents[1] / 'select_tests.py'
38+ 
39+ 
40+class SelectTestsTests(unittest.TestCase):
41+ def setUp(self):
42+ self.temp = tempfile.TemporaryDirectory(prefix='ops-tilelang-selector-')
43+ self.addCleanup(self.temp.cleanup)
44+ self.root = Path(self.temp.name)
45+ self.changed_file = self.root / 'changed.txt'
46+ self.trigger_file = self.root / 'full_test_triggers.conf'
47+ self.trigger_file.write_text('pyproject.toml\ntests/conftest.py\n', encoding='utf-8')
48+ self.write('src/cann_ops_tilelang/__init__.py', 'from .ops import bar, foo\n')
49+ self.write('src/cann_ops_tilelang/ops/__init__.py', 'from .bar import bar\nfrom .foo import foo\n')
50+ self.write(
51+ 'src/cann_ops_tilelang/ops/foo.py',
52+ 'from .foo_kernel import kernel\n\ndef foo():\n return kernel()\n',
53+ )
54+ self.write('src/cann_ops_tilelang/ops/foo_kernel.py', 'def kernel():\n return 1\n')
55+ self.write('src/cann_ops_tilelang/ops/bar.py', 'def bar():\n return 2\n')
56+ self.write('tests/__init__.py', '')
57+ self.write(
58+ 'tests/test_foo.py',
59+ 'from cann_ops_tilelang.ops.foo import foo\n\ndef test_foo():\n assert foo() == 1\n',
60+ )
61+ self.write(
62+ 'tests/test_bar.py',
63+ 'from cann_ops_tilelang.ops.bar import bar\n\ndef test_bar():\n assert bar() == 2\n',
64+ )
65+ 
66+ def write(self, relative, content):
67+ path = self.root / relative
68+ path.parent.mkdir(parents=True, exist_ok=True)
69+ path.write_text(content, encoding='utf-8')
70+ return path
71+ 
72+ def invoke(self, changed, *, extra_args=()):
73+ if changed is not None:
74+ self.changed_file.write_text('\n'.join(changed) + '\n', encoding='utf-8')
75+ return subprocess.run(
76+ [
77+ sys.executable,
78+ str(SELECTOR),
79+ '--root',
80+ str(self.root),
81+ '--changed-files',
82+ str(self.changed_file),
83+ '--full-test-triggers',
84+ str(self.trigger_file),
85+ *extra_args,
86+ ],
87+ capture_output=True,
88+ text=True,
89+ timeout=20,
90+ check=False,
91+ )
92+ 
93+ def assert_selection(self, changed, mode, targets=()):
94+ result = self.invoke(changed)
95+ diagnostic = f'stdout:\n{result.stdout}\nstderr:\n{result.stderr}'
96+ self.assertEqual(result.returncode, 0, diagnostic)
97+ lines = result.stdout.splitlines()
98+ self.assertTrue(lines, diagnostic)
99+ self.assertEqual(lines[0], mode, diagnostic)
100+ self.assertEqual(set(lines[1:]), set(targets), diagnostic)
101+ self.assertEqual(len(lines[1:]), len(targets), 'Targets must be deduplicated.\n' + diagnostic)
102+ return result
103+ 
104+ def test_absolute_import_selects_only_affected_test(self):
105+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'incremental', ['tests/test_foo.py'])
106+ 
107+ def test_relative_kernel_import_is_transitive(self):
108+ self.assert_selection(['src/cann_ops_tilelang/ops/foo_kernel.py'], 'incremental', ['tests/test_foo.py'])
109+ 
110+ def test_import_alias_and_module_attribute(self):
111+ self.write(
112+ 'tests/test_alias.py',
113+ 'import cann_ops_tilelang.ops.foo as foo_module\n'
114+ 'from cann_ops_tilelang.ops.foo import foo as aliased\n'
115+ '\ndef test_alias():\n assert foo_module.foo() == aliased()\n',
116+ )
117+ self.assert_selection(
118+ ['src/cann_ops_tilelang/ops/foo_kernel.py'],
119+ 'incremental',
120+ ['tests/test_foo.py', 'tests/test_alias.py'],
121+ )
122+ 
123+ def test_root_reexport_resolves_symbol_without_expanding_whole_package(self):
124+ self.write('tests/test_root_foo.py', 'from cann_ops_tilelang import foo\n')
125+ self.write('tests/test_root_bar.py', 'from cann_ops_tilelang import bar\n')
126+ self.assert_selection(
127+ ['src/cann_ops_tilelang/ops/foo_kernel.py'],
128+ 'incremental',
129+ ['tests/test_foo.py', 'tests/test_root_foo.py'],
130+ )
131+ 
132+ def test_root_package_alias_attribute_resolves_specific_export(self):
133+ self.write('tests/test_ops_foo.py', 'import cann_ops_tilelang as ops\n\ndef test_foo():\n ops.foo()\n')
134+ self.write('tests/test_ops_bar.py', 'import cann_ops_tilelang as ops\n\ndef test_bar():\n ops.bar()\n')
135+ self.assert_selection(
136+ ['src/cann_ops_tilelang/ops/foo_kernel.py'],
137+ 'incremental',
138+ ['tests/test_foo.py', 'tests/test_ops_foo.py'],
139+ )
140+ 
141+ def test_multiple_operators_and_tests_follow_transitive_dependency(self):
142+ self.write('src/cann_ops_tilelang/shared.py', 'def helper():\n return 1\n')
143+ self.write(
144+ 'src/cann_ops_tilelang/ops/foo_kernel.py',
145+ 'from ..shared import helper\n\ndef kernel():\n return helper()\n',
146+ )
147+ self.write(
148+ 'src/cann_ops_tilelang/ops/bar.py',
149+ 'from ..shared import helper\n\ndef bar():\n return helper()\n',
150+ )
151+ self.write(
152+ 'tests/test_both.py',
153+ 'from cann_ops_tilelang.ops.foo import foo\nfrom cann_ops_tilelang.ops.bar import bar\n',
154+ )
155+ self.assert_selection(
156+ ['src/cann_ops_tilelang/shared.py'],
157+ 'incremental',
158+ ['tests/test_foo.py', 'tests/test_bar.py', 'tests/test_both.py'],
159+ )
160+ 
161+ def test_cross_domain_quant_dependency_reaches_attention_and_normalization(self):
162+ self.write('src/cann_ops_tilelang/quant/__init__.py', 'from .per_token_cast import per_token_cast\n')
163+ self.write(
164+ 'src/cann_ops_tilelang/quant/per_token_cast.py',
165+ 'from .per_token_cast_kernel import kernel\n\ndef per_token_cast():\n return kernel()\n',
166+ )
167+ self.write('src/cann_ops_tilelang/quant/per_token_cast_kernel.py', 'def kernel():\n return 1\n')
168+ self.write(
169+ 'tests/quant/test_per_token_cast.py',
170+ 'from cann_ops_tilelang.quant.per_token_cast import per_token_cast\n',
171+ )
172+ self.write('tests/attention/test_cache.py', 'from cann_ops_tilelang.quant import per_token_cast as cast\n')
173+ self.write(
174+ 'tests/normalization/test_norm.py',
175+ 'from cann_ops_tilelang.quant.per_token_cast import per_token_cast\n',
176+ )
177+ self.assert_selection(
178+ ['src/cann_ops_tilelang/quant/per_token_cast_kernel.py'],
179+ 'incremental',
180+ ['tests/quant/test_per_token_cast.py', 'tests/attention/test_cache.py', 'tests/normalization/test_norm.py'],
181+ )
182+ 
183+ def test_relative_test_helper_import_is_followed(self):
184+ self.write('tests/reference.py', 'from cann_ops_tilelang.ops.foo import foo\n')
185+ self.write('tests/test_reference.py', 'from .reference import foo\n')
186+ self.assert_selection(['tests/reference.py'], 'incremental', ['tests/test_reference.py'])
187+ self.assert_selection(
188+ ['src/cann_ops_tilelang/ops/foo_kernel.py'],
189+ 'incremental',
190+ ['tests/test_foo.py', 'tests/test_reference.py'],
191+ )
192+ 
193+ def test_new_operator_and_nonmatching_test_name_need_no_mapping(self):
194+ self.write('src/cann_ops_tilelang/new_family/__init__.py', '')
195+ self.write('src/cann_ops_tilelang/new_family/brand_new.py', 'def operation():\n return 3\n')
196+ self.write(
197+ 'tests/new_family/test_unrelated_name.py',
198+ 'from cann_ops_tilelang.new_family.brand_new import operation\n',
199+ )
200+ self.assert_selection(
201+ ['src/cann_ops_tilelang/new_family/brand_new.py'],
202+ 'incremental',
203+ ['tests/new_family/test_unrelated_name.py'],
204+ )
205+ 
206+ def test_direct_test_change_runs_itself(self):
207+ self.assert_selection(['tests/test_foo.py'], 'incremental', ['tests/test_foo.py'])
208+ 
209+ def test_duplicate_paths_and_dependency_routes_are_deduplicated(self):
210+ self.assert_selection(
211+ [
212+ 'tests/test_foo.py',
213+ 'src/cann_ops_tilelang/ops/foo.py',
214+ 'src/cann_ops_tilelang/ops/foo_kernel.py',
215+ 'tests/test_foo.py',
216+ ],
217+ 'incremental',
218+ ['tests/test_foo.py'],
219+ )
220+ 
221+ def test_unknown_source_runs_full_scope(self):
222+ self.write('src/cann_ops_tilelang/ops/unreferenced.py', 'VALUE = 1\n')
223+ self.assert_selection(['src/cann_ops_tilelang/ops/unreferenced.py'], 'full', ['tests'])
224+ 
225+ def test_dynamic_local_import_runs_full_scope(self):
226+ self.write(
227+ 'tests/test_dynamic.py',
228+ 'import importlib\n\ndef test_dynamic():\n'
229+ ' module_name = "cann_ops_tilelang.ops.foo"\n'
230+ ' module = importlib.import_module(module_name)\n'
231+ ' module.foo()\n',
232+ )
233+ self.assert_selection(['src/cann_ops_tilelang/ops/foo_kernel.py'], 'full', ['tests'])
234+ 
235+ def test_dynamic_import_loader_aliases_cannot_hide_affected_tests(self):
236+ loaders = (
237+ 'import importlib\nload = importlib.import_module\n',
238+ 'load = __import__\n',
239+ 'from importlib import import_module as module_loader\nload = module_loader\n',
240+ )
241+ for loader in loaders:
242+ with self.subTest(loader=loader):
243+ self.write(
244+ 'tests/test_dynamic_alias.py',
245+ loader + '\ndef test_dynamic():\n load("cann_ops_tilelang.ops.foo")\n',
246+ )
247+ # The existing direct consumer must not mask this dynamic one.
248+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests'])
249+ 
250+ def test_package_namespace_assignment_runs_full_scope(self):
251+ self.write(
252+ 'tests/test_namespace.py',
253+ 'import cann_ops_tilelang as ops\nnamespace = ops.ops\n\ndef test_namespace():\n namespace.foo()\n',
254+ )
255+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests'])
256+ 
257+ def test_eval_import_runs_full_scope(self):
258+ self.write(
259+ 'tests/test_eval.py',
260+ 'def test_eval():\n eval("__import__(\'cann_ops_tilelang.ops.foo\')")\n',
261+ )
262+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests'])
263+ 
264+ def test_nested_conftest_change_runs_full_scope(self):
265+ self.write('tests/group/conftest.py', 'VALUE = 1\n')
266+ self.write('tests/group/test_implicit.py', 'def test_using_fixture(shared_fixture):\n pass\n')
267+ self.assert_selection(['tests/group/conftest.py'], 'full', ['tests'])
268+ 
269+ def test_helper_used_by_conftest_cannot_select_only_direct_consumers(self):
270+ self.write('tests/reference.py', 'VALUE = 1\n')
271+ self.write('tests/test_direct_helper.py', 'from tests.reference import VALUE\n')
272+ self.write('tests/group/conftest.py', 'from tests.reference import VALUE\n')
273+ self.write('tests/group/test_implicit.py', 'def test_using_fixture(shared_fixture):\n pass\n')
274+ self.assert_selection(['tests/reference.py'], 'full', ['tests'])
275+ 
276+ def test_plugin_helper_reaches_conftest_through_pytest_plugins(self):
277+ self.write('tests/reference.py', 'VALUE = 1\n')
278+ self.write('tests/test_direct_helper.py', 'from tests.reference import VALUE\n')
279+ self.write('tests/fixture_plugin.py', 'from tests.reference import VALUE\n')
280+ self.write('tests/group/conftest.py', 'pytest_plugins = ["tests.fixture_plugin"]\n')
281+ self.write('tests/group/test_implicit.py', 'def test_using_fixture(shared_fixture):\n pass\n')
282+ self.assert_selection(['tests/reference.py'], 'full', ['tests'])
283+ 
284+ def test_dynamic_pytest_plugins_runs_full_scope(self):
285+ self.write('tests/group/conftest.py', 'pytest_plugins = discover_plugins()\n')
286+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests'])
287+ 
288+ def test_package_star_import_runs_full_scope(self):
289+ self.write('tests/test_star.py', 'from cann_ops_tilelang import *\n')
290+ self.assert_selection(['src/cann_ops_tilelang/ops/foo_kernel.py'], 'full', ['tests'])
291+ 
292+ def test_syntax_error_runs_full_scope(self):
293+ self.write('src/cann_ops_tilelang/ops/foo.py', 'def foo(:\n')
294+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests'])
295+ 
296+ def test_deleted_source_runs_full_scope(self):
297+ (self.root / 'src/cann_ops_tilelang/ops/foo.py').unlink()
298+ self.assert_selection(['src/cann_ops_tilelang/ops/foo.py'], 'full', ['tests'])
299+ 
300+ def test_deleted_test_runs_full_scope(self):
301+ (self.root / 'tests/test_foo.py').unlink()
302+ self.assert_selection(['tests/test_foo.py'], 'full', ['tests'])
303+ 
304+ def test_package_initializers_run_full_scope(self):
305+ for changed in ('src/cann_ops_tilelang/__init__.py', 'src/cann_ops_tilelang/ops/__init__.py'):
306+ with self.subTest(changed=changed):
307+ self.assert_selection([changed], 'full', ['tests'])
308+ 
309+ def test_global_trigger_configuration_is_read(self):
310+ self.trigger_file.write_text('# policy comment\n\nrequirements*.txt\n', encoding='utf-8')
311+ self.write('requirements-dev.txt', 'pytest\n')
312+ self.assert_selection(['requirements-dev.txt'], 'full', ['tests'])
313+ 
314+ def test_global_trigger_dominates_incremental_selection(self):
315+ self.write('pyproject.toml', '[project]\nname = "test"\n')
316+ self.assert_selection(['tests/test_foo.py', 'pyproject.toml'], 'full', ['tests'])
317+ 
318+ def test_docs_only_skip(self):
319+ self.write('README.md', 'Documentation only.\n')
320+ self.write('docs/new_operator.md', 'Documentation only.\n')
321+ self.assert_selection(['README.md', 'docs/new_operator.md'], 'skip')
322+ 
323+ def test_ci_only_changes_skip_operator_tests(self):
324+ paths = (
325+ '.gitcode/config/full_test_triggers.conf',
326+ '.gitcode/pipeline.yml',
327+ '.gitcode/scripts/ut.sh',
328+ '.gitcode/scripts/select_tests.py',
329+ '.gitcode/scripts/tests/test_select_tests.py',
330+ )
331+ for path in paths:
332+ with self.subTest(path=path):
333+ self.write(path, '# CI adjustment\n')
334+ self.assert_selection([path], 'skip')
335+ (self.root / path).unlink()
336+ self.assert_selection([path], 'skip')
337+ self.assert_selection([*paths, 'README.md'], 'skip')
338+ 
339+ def test_ci_changes_preserve_source_selection(self):
340+ self.assert_selection(
341+ ['.gitcode/scripts/ut.sh', 'src/cann_ops_tilelang/ops/foo_kernel.py'],
342+ 'incremental',
343+ ['tests/test_foo.py'],
344+ )
345+ self.assert_selection(['.gitcode/scripts/ut.sh', 'pyproject.toml'], 'full', ['tests'])
346+ 
347+ def test_explicit_ci_full_test_rule_takes_precedence(self):
348+ self.trigger_file.write_text('pyproject.toml\n.gitcode/*\n', encoding='utf-8')
349+ self.assert_selection(['.gitcode/scripts/ut.sh'], 'full', ['tests'])
350+ 
351+ def test_unknown_changes_outside_ci_still_run_full_scope(self):
352+ self.assert_selection(['.gitcode-other/pipeline.yml'], 'full', ['tests'])
353+ 
354+ def test_missing_changed_file_is_error(self):
355+ result = self.invoke(None)
356+ self.assertNotEqual(result.returncode, 0, result.stdout)
357+ self.assertTrue(result.stderr)
358+ 
359+ def test_empty_changed_file_is_error(self):
360+ result = self.invoke([])
361+ self.assertNotEqual(result.returncode, 0, result.stdout)
362+ self.assertTrue(result.stderr)
363+ 
364+ def test_missing_trigger_config_is_error(self):
365+ self.trigger_file.unlink()
366+ result = self.invoke(['tests/test_foo.py'])
367+ self.assertNotEqual(result.returncode, 0, result.stdout)
368+ self.assertTrue(result.stderr)
369+ 
370+ def test_invalid_changed_path_is_error(self):
371+ result = self.invoke(['../outside.py'])
372+ self.assertNotEqual(result.returncode, 0, result.stdout)
373+ self.assertTrue(result.stderr)
374+ 
375+ def test_invalid_trigger_row_is_error(self):
376+ self.trigger_file.write_text('pyproject.toml unexpected-second-field\n', encoding='utf-8')
377+ result = self.invoke(['tests/test_foo.py'])
378+ self.assertNotEqual(result.returncode, 0, result.stdout)
379+ self.assertTrue(result.stderr)
380+ 
381+ 
382+if __name__ == '__main__':
383+ unittest.main()
@@ -6,14 +6,11 @@
6# Override the backend list with UT_TARGETS, e.g. UT_TARGETS="auto" or6# Override the backend list with UT_TARGETS, e.g. UT_TARGETS="auto" or
7# UT_TARGETS="pto".7# UT_TARGETS="pto".
8#8#
9-# Incremental mode (default when pr_filelist.txt exists): run changed9+# Incremental mode (default when pr_filelist.txt exists): statically discover
10-# pytest files directly and map changed operator sources to their pytest files;10+# tests affected by changed Python modules, including transitive imports.
11-# changes to configured shared files instead run the complete L0 test scope;11+# Shared-file changes or uncertain dependencies run the complete L0 scope.
12-# exit 0 if no changed file qualifies. Override with PR_FILELIST env var.12+# No operator-to-test mapping is required. Override the changed-file list with
13-# Operator mapping rules live in .gitcode/config/operator_tests.conf; override13+# PR_FILELIST and global full-test rules with FULL_TEST_TRIGGER_CONFIG.
14-# with OPERATOR_TEST_MAP. Full-test trigger rules live in
15-# .gitcode/config/full_test_triggers.conf; override with
16-# FULL_TEST_TRIGGER_CONFIG. Rules are loaded once in incremental mode.
17#14#
18# Optional overrides:15# Optional overrides:
19# DEVICE_ID=1,3 PYTEST_WORKERS=6 bash scripts/ut.sh16# DEVICE_ID=1,3 PYTEST_WORKERS=6 bash scripts/ut.sh
@@ -33,7 +30,6 @@ CONDA_ENV="${CONDA_ENV:-tl_tilelang0922ci}"
33DEVICE_ID="${DEVICE_ID:-${ASCEND_RT_VISIBLE_DEVICES:-2,3}}"30DEVICE_ID="${DEVICE_ID:-${ASCEND_RT_VISIBLE_DEVICES:-2,3}}"
34PYTEST_WORKERS="${PYTEST_WORKERS:-6}"31PYTEST_WORKERS="${PYTEST_WORKERS:-6}"
35PR_FILELIST="${PR_FILELIST:-${ROOT_DIR}/pr_filelist.txt}"32PR_FILELIST="${PR_FILELIST:-${ROOT_DIR}/pr_filelist.txt}"
36-OPERATOR_TEST_MAP="${OPERATOR_TEST_MAP:-${ROOT_DIR}/.gitcode/config/operator_tests.conf}"
37FULL_TEST_TRIGGER_CONFIG="${FULL_TEST_TRIGGER_CONFIG:-${ROOT_DIR}/.gitcode/config/full_test_triggers.conf}"33FULL_TEST_TRIGGER_CONFIG="${FULL_TEST_TRIGGER_CONFIG:-${ROOT_DIR}/.gitcode/config/full_test_triggers.conf}"
38 34 
39if [[ ! "${PYTEST_WORKERS}" =~ ^[1-9][0-9]*$ ]]; then35if [[ ! "${PYTEST_WORKERS}" =~ ^[1-9][0-9]*$ ]]; then
@@ -60,177 +56,37 @@ echo "Running regular pytest cases on NPU ${ASCEND_RT_VISIBLE_DEVICES} with ${PY
60if (( $# > 0 )); then56if (( $# > 0 )); then
61 pytest_targets=("$@")57 pytest_targets=("$@")
62elif [[ -f "${PR_FILELIST}" ]]; then58elif [[ -f "${PR_FILELIST}" ]]; then
63- # Incremental mode: pick changed pytest files and tests mapped from changed59+ python -m unittest discover -s "${ROOT_DIR}/.gitcode/scripts/tests"
64- # operator sources in pr_filelist.txt.60+ # Capture the command first so selector failures propagate under set -e.
65- pytest_targets=()61+ selection_output="$(python "${ROOT_DIR}/.gitcode/scripts/select_tests.py" \
66- 62+ --root "${ROOT_DIR}" \
67- add_pytest_target() {63+ --changed-files "${PR_FILELIST}" \
68- local test_path="$1"64+ --full-test-triggers "${FULL_TEST_TRIGGER_CONFIG}")"
69- local existing65+ mapfile -t selection <<< "${selection_output}"
70- 66+ case "${selection[0]}" in
71- # Deleted or not-yet-migrated tests cannot be passed to pytest.67+ full)
72- if [[ ! -f "${ROOT_DIR}/${test_path}" ]]; then68+ pytest_targets=(tests)
73- return69+ export OPS_TILELANG_TEST_LEVEL=0
74- fi70+ echo "Full L0 UT selected by changed-file analysis."
75- for existing in "${pytest_targets[@]}"; do71+ ;;
76- if [[ "${existing}" == "${test_path}" ]]; then72+ incremental)
77- return73+ pytest_targets=("${selection[@]:1}")
74+ if (( ${#pytest_targets[@]} == 0 )); then
75+ echo "Test selector returned an empty incremental scope." >&2
76+ exit 2
78 fi77 fi
79- done78+ echo "Incremental UT on ${#pytest_targets[@]} affected pytest file(s):"
80- pytest_targets+=("${test_path}")79+ printf ' %s\n' "${pytest_targets[@]}"
81- }80+ ;;
82- 81+ skip)
83- load_full_test_triggers() {82+ echo "No affected executable files; skip UT."
84- local config_file="$1"
85- local line line_number=0
86- local -a fields=()
87- full_test_source_patterns=()
88- 
89- if [[ ! -f "${config_file}" || ! -r "${config_file}" ]]; then
90- echo "Full-test trigger config is missing or unreadable: ${config_file}" >&2
91- return 2
92- fi
93- 
94- while IFS= read -r line || [[ -n "${line}" ]]; do
95- ((line_number += 1))
96- line="${line%$'\r'}"
97- read -r -a fields <<< "${line}"
98- if (( ${#fields[@]} == 0 )) || [[ "${fields[0]}" == \#* ]]; then
99- continue
100- fi
101- if (( ${#fields[@]} != 1 )); then
102- echo "${config_file}:${line_number}: expected exactly one source pattern" >&2
103- return 2
104- fi
105- full_test_source_patterns+=("${fields[0]}")
106- done < "${config_file}"
107- 
108- if (( ${#full_test_source_patterns[@]} == 0 )); then
109- echo "Full-test trigger config contains no patterns: ${config_file}" >&2
110- return 2
111- fi
112- }
113- 
114- match_full_test_trigger() {
115- local file_path="$1"
116- local index
117- 
118- for ((index = 0; index < ${#full_test_source_patterns[@]}; index++)); do
119- # Leave the right-hand side unquoted to apply the configured Bash glob.
120- if [[ "${file_path}" == ${full_test_source_patterns[index]} ]]; then
121- full_test_changed_file="${file_path}"
122- full_test_matched_pattern="${full_test_source_patterns[index]}"
123- return 0
124- fi
125- done
126- return 1
127- }
128- 
129- load_operator_test_map() {
130- local map_file="$1"
131- local line line_number=0 test_path
132- local -a fields=()
133- operator_source_patterns=()
134- operator_test_targets=()
135- 
136- if [[ ! -f "${map_file}" || ! -r "${map_file}" ]]; then
137- echo "Operator test map is missing or unreadable: ${map_file}" >&2
138- return 2
139- fi
140- 
141- while IFS= read -r line || [[ -n "${line}" ]]; do
142- ((line_number += 1))
143- line="${line%$'\r'}"
144- read -r -a fields <<< "${line}"
145- if (( ${#fields[@]} == 0 )) || [[ "${fields[0]}" == \#* ]]; then
146- continue
147- fi
148- if (( ${#fields[@]} < 2 )); then
149- echo "${map_file}:${line_number}: expected a source pattern and test paths" >&2
150- return 2
151- fi
152- for test_path in "${fields[@]:1}"; do
153- if [[ "${test_path}" != tests/*.py || "${test_path}" == */../* ]]; then
154- echo "${map_file}:${line_number}: invalid test path: ${test_path}" >&2
155- return 2
156- fi
157- done
158- operator_source_patterns+=("${fields[0]}")
159- operator_test_targets+=("${fields[*]:1}")
160- done < "${map_file}"
161- }
162- 
163- map_operator_tests() {
164- local file_path="$1"
165- local index test_path
166- local -a targets=()
167- 
168- for ((index = 0; index < ${#operator_source_patterns[@]}; index++)); do
169- # Leave the right-hand side unquoted to apply the configured Bash glob.
170- if [[ "${file_path}" == ${operator_source_patterns[index]} ]]; then
171- read -r -a targets <<< "${operator_test_targets[index]}"
172- for test_path in "${targets[@]}"; do
173- add_pytest_target "${test_path}"
174- done
175- # Match the original case statement: the first matching rule wins.
176- return 0
177- fi
178- done
179- return 0
180- }
181- 
182- load_full_test_triggers "${FULL_TEST_TRIGGER_CONFIG}"
183- load_operator_test_map "${OPERATOR_TEST_MAP}"
184- 
185- mapfile -t changed_lines < "${PR_FILELIST}"
186- full_test_triggered=0
187- for file_path in "${changed_lines[@]}"
188- do
189- file_path="$(echo "${file_path}" | xargs)"
190- if [[ -z "${file_path}" ]]; then
191- continue
192- fi
193- if match_full_test_trigger "${file_path}"; then
194- full_test_triggered=1
195- break
196- fi
197- done
198- 
199- if (( full_test_triggered )); then
200- pytest_targets=(tests)
201- export OPS_TILELANG_TEST_LEVEL=0
202- echo "Full L0 UT triggered by shared-file change:"
203- echo " changed file: ${full_test_changed_file}"
204- echo " matched rule: ${full_test_matched_pattern}"
205- echo " pytest target: tests"
206- echo " OPS_TILELANG_TEST_LEVEL=${OPS_TILELANG_TEST_LEVEL}"
207- else
208- for file_path in "${changed_lines[@]}"
209- do
210- # Keep the committed name-only file-list parsing behavior.
211- file_path="$(echo "${file_path}" | xargs)"
212- if [[ -z "${file_path}" ]]; then
213- continue
214- fi
215- 
216- # Keep the existing behavior for directly changed pytest files.
217- if [[ "${file_path}" == tests/* ]]; then
218- base_name="$(basename -- "${file_path}")"
219- if [[ "${base_name}" == test_*.py || "${base_name}" == *_test.py ]]; then
220- add_pytest_target "${file_path}"
221- continue
222- fi
223- fi
224- 
225- map_operator_tests "${file_path}"
226- done
227- if (( ${#pytest_targets[@]} == 0 )); then
228- echo "No changed pytest file, mapped operator test, or full-test trigger found; skip UT."
229 exit 083 exit 0
230- fi84+ ;;
231- echo "Incremental UT on ${#pytest_targets[@]} changed or mapped pytest file(s):"85+ *)
232- printf ' %s\n' "${pytest_targets[@]}"86+ echo "Invalid test selector result: ${selection[0]}" >&2
233- fi87+ exit 2
88+ ;;
89+ esac
234else90else
235 pytest_targets=(tests)91 pytest_targets=(tests)
236fi92fi
@@ -9,6 +9,14 @@
9- `pytest_xdist_failfast_plugin.py`:并行 `-x` 时停止全部 worker;9- `pytest_xdist_failfast_plugin.py`:并行 `-x` 时停止全部 worker;
10- `pytest_tilelang_warning_plugin.py`:在终端汇总 TileLang 编译警告。10- `pytest_tilelang_warning_plugin.py`:在终端汇总 TileLang 编译警告。
11 11 
12+## CI 增量测试
13+ 
14+CI 根据 `pr_filelist.txt` 中的变更,通过 `.gitcode/scripts/select_tests.py` 静态分析源码和测试的导入关系,自动选择直接或间接受影响的测试。新增算子及测试无需登记映射;测试应通过常规 Python 导入引用被测实现和参考实现。
15+ 
16+共享配置、包入口、删除文件或无法确定依赖关系的代码变更保守执行全仓 L0。全局触发策略保留在 `.gitcode/config/full_test_triggers.conf`,其中不维护算子与测试的对应关系。选择器的 CPU 回归测试可用 `python -m unittest discover -s .gitcode/scripts/tests` 运行。
17+ 
18+`.gitcode/` 下的 CI 文件默认不触发算子测试,便于管理员在机器故障等场景调整执行逻辑。仅修改 CI 文件或文档时跳过算子用例;同时修改源码或测试时,仍正常选择受影响的测试。显式配置的全量触发规则优先,管理员可按需添加 CI 路径规则;选择器的 CPU 回归测试仍会执行。
19+ 
12## 测试分级20## 测试分级
13 21 
14`OPS_TILELANG_TEST_LEVEL` 取值为 `0`、`1`、`2`,分别表示核心、默认和完整参数集合。参数生成器位于 `tests/testing/generator.py`。22`OPS_TILELANG_TEST_LEVEL` 取值为 `0`、`1`、`2`,分别表示核心、默认和完整参数集合。参数生成器位于 `tests/testing/generator.py`。