owfadev 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.agents/skills/owfa-check/SKILL.md +49 -0
  2. package/.agents/skills/{docs → owfa-docs}/SKILL.md +5 -5
  3. package/.agents/skills/{wiki_plugin_kernel → owfa-kernel}/SKILL.md +13 -12
  4. package/{.meta/command/plugin → .agents/skills/owfa-plugin}/SKILL.md +10 -10
  5. package/.agents/skills/{query → owfa-query}/SKILL.md +5 -6
  6. package/.owfa/VERSION +1 -0
  7. package/.owfa/command/owfa-check/SKILL.md +49 -0
  8. package/{.meta/command/docs → .owfa/command/owfa-docs}/SKILL.md +5 -5
  9. package/{.meta/command/wiki_plugin_kernel → .owfa/command/owfa-kernel}/SKILL.md +13 -12
  10. package/{.agents/skills/plugin → .owfa/command/owfa-plugin}/SKILL.md +10 -10
  11. package/{.meta/command/query → .owfa/command/owfa-query}/SKILL.md +5 -6
  12. package/{.meta → .owfa}/docs/agent-behavior.md +3 -2
  13. package/{.meta → .owfa}/docs/agents-example.md +1 -1
  14. package/{.meta → .owfa}/docs/conversion.md +2 -2
  15. package/{.meta → .owfa}/docs/deployment.md +51 -46
  16. package/{.meta → .owfa}/docs/developer.md +4 -4
  17. package/{.meta → .owfa}/docs/distribution.md +21 -19
  18. package/.owfa/docs/home.md +61 -0
  19. package/{.meta → .owfa}/docs/intro.md +5 -5
  20. package/{.meta → .owfa}/docs/log-example.md +1 -1
  21. package/{.meta → .owfa}/docs/map.md +4 -3
  22. package/{.meta → .owfa}/docs/mechanics.md +11 -11
  23. package/{.meta → .owfa}/docs/naming.md +7 -7
  24. package/{.meta → .owfa}/docs/packages.md +80 -15
  25. package/.owfa/docs/plugin-lifecycle.md +70 -0
  26. package/{.meta → .owfa}/docs/quickstart.md +12 -12
  27. package/{.meta → .owfa}/docs/research-user-profile.md +1 -1
  28. package/{.meta → .owfa}/docs/sandbox.md +3 -3
  29. package/{.meta → .owfa}/docs/usage.md +1 -1
  30. package/{.meta → .owfa}/protocol/actions.md +6 -6
  31. package/.owfa/protocol/registry.yaml +34 -0
  32. package/.owfa/scripts/owfa_home.py +273 -0
  33. package/{.meta/scripts/wiki_plugin_kernel.py → .owfa/scripts/owfa_kernel.py} +521 -77
  34. package/{.meta → .owfa}/scripts/pipeline.py +9 -9
  35. package/.owfa/scripts/wiki_plugin_kernel.py +15 -0
  36. package/{.meta → .owfa}/scripts/wikilib.py +2 -2
  37. package/.owfa/tests/test_capabilities.py +311 -0
  38. package/{.meta → .owfa}/tests/test_install_preflight.py +11 -7
  39. package/.owfa/tests/test_owfa_home.py +127 -0
  40. package/.owfa/tests/test_version_scheme.py +63 -0
  41. package/README.md +32 -24
  42. package/README.zh-CN.md +32 -24
  43. package/bin/owfa.js +525 -65
  44. package/package.json +3 -2
  45. package/payloads/personal/PKG.yaml +15 -0
  46. package/payloads/personal/README.md +7 -0
  47. package/{.meta/plugins/core → payloads/personal}/calendar/PLUGIN.md +1 -0
  48. package/{.meta/plugins/core → payloads/personal}/calendar/PLUGIN.yaml +3 -3
  49. package/{.agents/skills/map → payloads/personal/command/owfa-map}/SKILL.md +9 -9
  50. package/{.meta/command/profile → payloads/personal/command/owfa-profile}/SKILL.md +11 -11
  51. package/{.agents/skills/save → payloads/personal/command/owfa-save}/SKILL.md +10 -10
  52. package/{.meta/plugins/core → payloads/personal}/cron/PLUGIN.md +2 -0
  53. package/{.meta/plugins/core → payloads/personal}/cron/PLUGIN.yaml +3 -3
  54. package/{.meta/plugins/core → payloads/personal}/device/PLUGIN.md +1 -0
  55. package/{.meta/plugins/core → payloads/personal}/device/PLUGIN.yaml +3 -3
  56. package/{.meta/plugins/core → payloads/personal}/domain/PLUGIN.md +2 -0
  57. package/payloads/personal/domain/PLUGIN.yaml +8 -0
  58. package/{.meta/plugins/core → payloads/personal}/hot/PLUGIN.yaml +1 -1
  59. package/{.meta/plugins/core → payloads/personal}/index/PLUGIN.md +1 -0
  60. package/{.meta/plugins/core → payloads/personal}/index/PLUGIN.yaml +4 -4
  61. package/{.meta/plugins/core → payloads/personal}/instance-git/PLUGIN.md +5 -0
  62. package/{.meta/plugins/core → payloads/personal}/instance-git/PLUGIN.yaml +3 -3
  63. package/{.meta/plugins/core → payloads/personal}/language/PLUGIN.md +1 -0
  64. package/{.meta/plugins/core → payloads/personal}/language/PLUGIN.yaml +4 -4
  65. package/{.meta/plugins/core → payloads/personal}/link/PLUGIN.md +1 -0
  66. package/{.meta/plugins/core → payloads/personal}/link/PLUGIN.yaml +3 -3
  67. package/{.meta/plugins/core → payloads/personal}/link/scripts/check.py +1 -1
  68. package/{.meta/plugins/core → payloads/personal}/log/PLUGIN.md +1 -0
  69. package/{.meta/plugins/core → payloads/personal}/log/PLUGIN.yaml +4 -4
  70. package/{.meta/plugins/core → payloads/personal}/mapping/PLUGIN.yaml +1 -1
  71. package/{.meta/plugins/core → payloads/personal}/notes/PLUGIN.md +1 -0
  72. package/{.meta/plugins/core → payloads/personal}/notes/PLUGIN.yaml +4 -4
  73. package/{.meta/plugins/core → payloads/personal}/sessions/PLUGIN.md +2 -1
  74. package/{.meta/plugins/core → payloads/personal}/sessions/PLUGIN.yaml +5 -5
  75. package/{.meta/plugins/core → payloads/personal}/structure/PLUGIN.md +2 -0
  76. package/{.meta/plugins/core → payloads/personal}/structure/PLUGIN.yaml +3 -3
  77. package/{.meta/plugins/core → payloads/personal}/structure/scripts/check.py +1 -1
  78. package/{.meta/plugins/core → payloads/personal}/tag/PLUGIN.md +2 -0
  79. package/{.meta/plugins/core → payloads/personal}/tag/PLUGIN.yaml +3 -3
  80. package/{.meta/plugins/core → payloads/personal}/tag/scripts/check.py +3 -1
  81. package/{.meta/plugins/core → payloads/personal}/tmp/PLUGIN.md +1 -0
  82. package/{.meta/plugins/core → payloads/personal}/tmp/PLUGIN.yaml +3 -3
  83. package/{.meta/plugins/core → payloads/personal}/todo/PLUGIN.md +2 -0
  84. package/{.meta/plugins/core → payloads/personal}/todo/PLUGIN.yaml +6 -5
  85. package/payloads/personal/todo/scripts/check.py +51 -0
  86. package/{.meta/plugins/core → payloads/personal}/trust/PLUGIN.md +1 -0
  87. package/{.meta/plugins/core → payloads/personal}/trust/PLUGIN.yaml +3 -3
  88. package/{.meta/plugins/core → payloads/personal}/user-profile/PLUGIN.md +2 -0
  89. package/{.meta/plugins/core → payloads/personal}/user-profile/PLUGIN.yaml +7 -6
  90. package/payloads/personal/user-profile/scripts/check.py +20 -0
  91. package/{.meta/plugins/core → payloads/personal}/vault/PLUGIN.md +1 -0
  92. package/payloads/personal/vault/PLUGIN.yaml +13 -0
  93. package/{.meta/plugins/core → payloads/personal}/wiki/PLUGIN.md +1 -0
  94. package/payloads/personal/wiki/PLUGIN.yaml +8 -0
  95. package/templates/AGENTS.template.md +2 -2
  96. package/.agents/skills/check/SKILL.md +0 -130
  97. package/.agents/skills/profile/SKILL.md +0 -75
  98. package/.meta/VERSION +0 -1
  99. package/.meta/command/check/SKILL.md +0 -130
  100. package/.meta/command/map/SKILL.md +0 -72
  101. package/.meta/command/save/SKILL.md +0 -94
  102. package/.meta/plugins/core/domain/PLUGIN.yaml +0 -8
  103. package/.meta/plugins/core/vault/PLUGIN.yaml +0 -13
  104. package/.meta/plugins/core/wiki/PLUGIN.yaml +0 -8
  105. package/.meta/protocol/registry.yaml +0 -75
  106. /package/{.meta → .owfa}/command/asset-read/SKILL.md +0 -0
  107. /package/{.meta → .owfa}/docs/research-landscape.md +0 -0
  108. /package/{.meta → .owfa}/protocol/experiments.md +0 -0
  109. /package/{.meta/plugins/core → payloads/personal}/device/scripts/check.py +0 -0
  110. /package/{.meta/plugins/core → payloads/personal}/hot/PLUGIN.md +0 -0
  111. /package/{.meta/plugins/core → payloads/personal}/instance-git/scripts/check.py +0 -0
  112. /package/{.meta/plugins/core → payloads/personal}/language/scripts/check.py +0 -0
  113. /package/{.meta/plugins/core → payloads/personal}/log/scripts/check.py +0 -0
  114. /package/{.meta/plugins/core → payloads/personal}/mapping/PLUGIN.md +0 -0
  115. /package/{.meta/plugins/core → payloads/personal}/mapping/scripts/check.py +0 -0
  116. /package/{.meta/plugins/core → payloads/personal}/sessions/scripts/check.py +0 -0
  117. /package/{.meta/plugins/core → payloads/personal}/trust/scripts/check.py +0 -0
@@ -2,7 +2,7 @@
2
2
  # -*- coding: utf-8 -*-
3
3
  """Derived-layer post-write pipeline: index / tags / hot / log / verify.
4
4
 
5
- Positioning (.meta/protocol/actions.md execution principles): deterministic structural
5
+ Positioning (.owfa/protocol/actions.md execution principles): deterministic structural
6
6
  operations go to this script; semantic judgment goes to the LLM. Rebuild and rolling
7
7
  rules and window parameters are governed by this file's source (the script source is
8
8
  the rule list); PLUGIN.md keeps the semantic explanation; commands uniformly end by
@@ -11,11 +11,11 @@ post-write self-attestation (the minimal attester form): the LLM executes, the s
11
11
  certifies.
12
12
 
13
13
  Usage:
14
- python .meta/scripts/pipeline.py index # rebuild indexes (overflow-offloading scheme, idempotent; includes deleting surplus indexes after merging back)
15
- python .meta/scripts/pipeline.py tags # rebuild wiki/tags.md (idempotent)
16
- python .meta/scripts/pipeline.py hot <type> <one-liner> # evict out-of-window entries + prepend a hot-cache entry
17
- python .meta/scripts/pipeline.py log <type> <one-liner> [--domain domain] # capacity archive + prepend a log line (domain tag optional — domainless transactions)
18
- python .meta/scripts/pipeline.py verify # post-write self-attestation: attached audits + derived-area drift check
14
+ python .owfa/scripts/pipeline.py index # rebuild indexes (overflow-offloading scheme, idempotent; includes deleting surplus indexes after merging back)
15
+ python .owfa/scripts/pipeline.py tags # rebuild wiki/tags.md (idempotent)
16
+ python .owfa/scripts/pipeline.py hot <type> <one-liner> # evict out-of-window entries + prepend a hot-cache entry
17
+ python .owfa/scripts/pipeline.py log <type> <one-liner> [--domain domain] # capacity archive + prepend a log line (domain tag optional — domainless transactions)
18
+ python .owfa/scripts/pipeline.py verify # post-write self-attestation: attached audits + derived-area drift check
19
19
 
20
20
  Type enumeration: map / save / query / check / plugin / todo / profile / other (the log plugin).
21
21
  Concept-page determination (what enters the index): all .md under wiki/, excluding —
@@ -54,9 +54,9 @@ LOG_MAX_ENTRIES = 100
54
54
  INDEX_MAX_ENTRIES = 25 # single index-list window (page entries + directory entries; overflow-offloading split basis)
55
55
  TYPE_ORDER = ["map", "save", "query", "check", "plugin", "todo", "profile", "other"]
56
56
  HOT_HEADER = ("# 热缓存\n\n> 最近变更摘要;≤25 条且 <5 日,窗外即删;可整体再生。"
57
- "规则见 `.meta/plugins/hot/`,写走 `pipeline.py hot`。\n")
57
+ "规则见 `.owfa/plugins/hot/`,写走 `pipeline.py hot`。\n")
58
58
  LOG_HEADER = ("# 运行日志\n\n> 置顶追加、只增不删;条目 = `- YYYY-MM-DD <类型> [<域>]:一句话`(域标可缺省——无域事务,log 桥)。"
59
- "规则见 `.meta/plugins/log/`,写走 `pipeline.py log`。\n")
59
+ "规则见 `.owfa/plugins/log/`,写走 `pipeline.py log`。\n")
60
60
  DATE_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2}) ")
61
61
 
62
62
 
@@ -391,7 +391,7 @@ def do_log(kind, text, domain=None):
391
391
  def do_verify():
392
392
  ok = True
393
393
  r = subprocess.run(
394
- [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "wiki_plugin_kernel.py"), "audit"])
394
+ [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "owfa_kernel.py"), "audit"])
395
395
  if r.returncode != 0:
396
396
  ok = False
397
397
  print("[verify] plugin audit has errors")
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Deprecation shim: the kernel was renamed owfa_kernel.py (0.4 layout).
4
+
5
+ Keeps 0.3-era paths and muscle memory working through the 0.4 cycle; the shim
6
+ is scheduled for removal in 0.5. The real entry is owfa_kernel.py next to it.
7
+ """
8
+ import os
9
+ import runpy
10
+ import sys
11
+
12
+ _HERE = os.path.dirname(os.path.abspath(__file__))
13
+ print("[deprecated] wiki_plugin_kernel.py -> owfa_kernel.py (shim retires in 0.5)", file=sys.stderr)
14
+ sys.argv[0] = os.path.join(_HERE, "owfa_kernel.py")
15
+ runpy.run_path(os.path.join(_HERE, "owfa_kernel.py"), run_name="__main__")
@@ -3,9 +3,9 @@
3
3
  """Wiki page-service load-bearing piece: frontmatter parsing and page traversal, assembled into ctx for the attached-audit runtime.
4
4
 
5
5
  Plugin attached-audit scripts do not import this module themselves —
6
- wiki_plugin_kernel.audit scans wiki/ in a single pass and puts the (relative path,
6
+ owfa_kernel.audit scans wiki/ in a single pass and puts the (relative path,
7
7
  frontmatter, body) list into ctx.pages for everyone to share (frugal invocation).
8
- The same minimal YAML subset as wiki_plugin_kernel.parse_manifest, but with a
8
+ The same minimal YAML subset as owfa_kernel.parse_manifest, but with a
9
9
  different tolerance policy: manifests fail loudly (protocol artifacts), page
10
10
  frontmatter skips bad lines (user content must not crash attached audits over
11
11
  format blemishes). Keys are lenient (anything non-blank and colon-free):
@@ -0,0 +1,311 @@
1
+ """v0.4 capability (needs/provides) + suite-kind + INDEX-extension unit tests.
2
+
3
+ Synthetic packages and disposable instances only; registry-channel cases use a
4
+ local git repo + a local ROOT/INDEX.yaml (load_index reads the local file first,
5
+ so no network is ever touched).
6
+
7
+ Run: python -m unittest discover -s .owfa/tests -p test_capabilities.py -v
8
+ """
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+
18
+
19
+ FRAMEWORK = Path(__file__).resolve().parents[2]
20
+
21
+
22
+ class CapabilityTests(unittest.TestCase):
23
+ def setUp(self):
24
+ self.temp = tempfile.TemporaryDirectory(prefix="kernel-cap-test-")
25
+ self.addCleanup(self.temp.cleanup)
26
+ self.root = Path(self.temp.name) / "instance"
27
+ self.root.mkdir()
28
+ for directory in (".owfa", ".agents"):
29
+ shutil.copytree(FRAMEWORK / directory, self.root / directory,
30
+ ignore=shutil.ignore_patterns("__pycache__", "tests", "smoke-tmp"))
31
+ (self.root / "AGENTS.md").write_text(
32
+ "# Synthetic instance\n<!-- wiki-inject:start -->\n<!-- wiki-inject:end -->\n",
33
+ encoding="utf-8")
34
+ self.source = Path(self.temp.name) / "source"
35
+ self.source.mkdir()
36
+ self.env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", PYTHONIOENCODING="utf-8")
37
+ self.env.pop("PYTHONPATH", None)
38
+ # v0.4 final shape: the framework tree ships no plugins — the synthetic
39
+ # instances bootstrap exactly like a real init (embedded personal suite)
40
+ result = self.run_kernel("install", "personal", "--path", str(FRAMEWORK / "payloads" / "personal"))
41
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
42
+ result = self.run_kernel("all")
43
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
44
+
45
+ def run_kernel(self, *args):
46
+ return subprocess.run(
47
+ [sys.executable, str(self.root / ".owfa/scripts/owfa_kernel.py"), *args],
48
+ cwd=self.root, env=self.env, capture_output=True, text=True, encoding="utf-8")
49
+
50
+ # ---- synthetic package builders ----
51
+
52
+ def manifest(self, name, inject, depends="[domain, wiki, trust, log]", parent=None):
53
+ directory = (parent or self.source) / name
54
+ directory.mkdir(parents=True, exist_ok=True)
55
+ dependencies = "".join(f" - {item.strip()}\n" for item in depends.strip("[]").split(","))
56
+ (directory / "PLUGIN.yaml").write_text(
57
+ f"id: {name}\nschema_version: 1\nversion: 0.1\nupdated: 2026-11-12\n"
58
+ f"depends:\n{dependencies}attachment: []\nfields:\n"
59
+ f"inject: {json.dumps(inject, ensure_ascii=False)}\n", encoding="utf-8")
60
+ (directory / "PLUGIN.md").write_text("# Synthetic test plugin\n", encoding="utf-8")
61
+
62
+ def pkg_dir(self, pid, caps="", version="0.1"):
63
+ """A package root self.source/<pid>pkg/ holding PKG.yaml + the member dir."""
64
+ directory = self.source / f"{pid}pkg"
65
+ if not (directory / pid).exists():
66
+ self.manifest(pid, f"{pid} synthetic member", parent=directory)
67
+ lines = ["schema_version: 1", f"id: {pid}", f"version: {version}", "compat: '>= 0.3'",
68
+ f"members: [{pid}]", "depends: [framework]"]
69
+ lines += [l for l in caps.splitlines() if l.strip()]
70
+ (directory / "PKG.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
71
+ return directory
72
+
73
+ def install_path(self, pid, *extra):
74
+ return self.run_kernel("install", pid, "--path", str(self.source / f"{pid}pkg"), *extra)
75
+
76
+ def git_pkg(self, pid, version, provides=None, needs=None):
77
+ """A local git repo package (registry-channel source) + tags; returns the repo path."""
78
+ repo = Path(self.temp.name) / "git" / pid
79
+ if repo.exists():
80
+ shutil.rmtree(repo)
81
+ repo.mkdir(parents=True)
82
+ self.manifest(pid, f"{pid} registry member", parent=repo)
83
+ lines = ["schema_version: 1", f"id: {pid}", f"version: {version}", "compat: '>= 0.3'",
84
+ f"members: [{pid}]", "depends: [framework]"]
85
+ if provides:
86
+ lines.append(f"provides: [{', '.join(provides)}]")
87
+ if needs:
88
+ lines.append(f"needs: [{', '.join(needs)}]")
89
+ (repo / "PKG.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
90
+ self.git(repo, "init", "-q")
91
+ self.git(repo, "add", "-A")
92
+ self.git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "pkg")
93
+ self.git(repo, "tag", f"{pid}-v{version}")
94
+ return repo
95
+
96
+ def git(self, repo, *args):
97
+ r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
98
+ self.assertEqual(r.returncode, 0, r.stderr)
99
+ return r
100
+
101
+ def git_head(self, repo):
102
+ return subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"],
103
+ capture_output=True, text=True).stdout.strip()
104
+
105
+ def index(self, rows):
106
+ lines = ["packages:"] + [f" {name}: {{{body}}}" for name, body in rows]
107
+ (self.root / "INDEX.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
108
+
109
+ # ---- grammar / prefix ----
110
+
111
+ def test_grammar_and_prefix_rule(self):
112
+ self.pkg_dir("probe", caps="needs: [Bad.Cap]\nprovides: [other.org.v1]")
113
+ result = self.install_path("probe")
114
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
115
+ self.assertIn("fails the capability grammar", result.stdout)
116
+ self.assertIn("first name segment must equal the package id", result.stdout)
117
+ self.assertFalse((self.root / ".owfa/plugins/domains/probe").exists())
118
+ # the prefix message and the grammar message are both reported (one per entry)
119
+
120
+ def test_owfa_prefix_and_own_namespace_pass_grammar(self):
121
+ # owfa.* is the framework-owned namespace; a non-owfa capability named after
122
+ # the declaring package is the legal third-party form — both pass grammar
123
+ self.pkg_dir("probe", caps="provides: [probe.transport.v1, owfa.net.v1]")
124
+ result = self.install_path("probe")
125
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
126
+
127
+ # ---- exact-name matching ----
128
+
129
+ def test_v2_gap_is_reported_as_different_version(self):
130
+ self.pkg_dir("prov", caps="provides: [owfa.org.v1]")
131
+ self.assertEqual(self.install_path("prov").returncode, 0)
132
+ self.pkg_dir("needr", caps="needs: [owfa.org.v2]")
133
+ result = self.install_path("needr")
134
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
135
+ self.assertIn("present at different version", result.stdout)
136
+ self.assertIn("exact-name", result.stdout)
137
+
138
+ def test_unknown_capability_is_missing_entirely(self):
139
+ self.pkg_dir("needr", caps="needs: [owfa.im.v1]")
140
+ result = self.install_path("needr")
141
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
142
+ self.assertIn("capability missing entirely", result.stdout)
143
+ self.assertNotIn("different version", result.stdout)
144
+
145
+ # ---- exclusive slot ----
146
+
147
+ def test_exclusive_slot_refuses_second_provider(self):
148
+ self.pkg_dir("alpha", caps="provides: [owfa.org.v1]")
149
+ self.assertEqual(self.install_path("alpha").returncode, 0)
150
+ self.pkg_dir("beta", caps="provides: [owfa.org.v1]")
151
+ result = self.install_path("beta")
152
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
153
+ self.assertIn("exclusive", result.stdout)
154
+ self.assertIn("alpha", result.stdout)
155
+ self.assertIn("beta", result.stdout)
156
+ self.assertFalse((self.root / ".owfa/plugins/domains/beta").exists())
157
+
158
+ # ---- trust gate ----
159
+
160
+ def test_unverified_provider_needs_yes_and_journals(self):
161
+ # provider installed via --path (no INDEX row): provides are unverified
162
+ self.pkg_dir("prov", caps="provides: [owfa.org.v1]")
163
+ self.assertEqual(self.install_path("prov").returncode, 0)
164
+ self.pkg_dir("needr", caps="needs: [owfa.org.v1]")
165
+ result = self.install_path("needr")
166
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
167
+ self.assertIn("unverified trust", result.stdout)
168
+ journal = (self.root / ".owfa/kernel-journal.log").read_text(encoding="utf-8")
169
+ self.assertNotIn("trust-override", journal)
170
+ result = self.install_path("needr", "--yes")
171
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
172
+ journal = (self.root / ".owfa/kernel-journal.log").read_text(encoding="utf-8")
173
+ self.assertIn("trust-override needr", journal)
174
+
175
+ def test_reviewed_registry_provider_satisfies_needs(self):
176
+ repo = self.git_pkg("prov", "0.1", provides=["owfa.org.v1"])
177
+ self.index([("prov", f"repo: {repo}, version: 0.1, trust: reviewed-3rd-party, members: prov")])
178
+ self.assertEqual(self.run_kernel("install", "prov").returncode, 0)
179
+ self.pkg_dir("needr", caps="needs: [owfa.org.v1]")
180
+ result = self.install_path("needr") # no --yes: the trusted provider satisfies
181
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
182
+
183
+ # ---- prospective set (upgrade sole provider) ----
184
+
185
+ def test_upgrade_of_sole_provider_passes_prospective_preflight(self):
186
+ repo = self.git_pkg("prov", "0.1", provides=["owfa.org.v1"])
187
+ self.index([("prov", f"repo: {repo}, version: 0.1, trust: reviewed-3rd-party, members: prov")])
188
+ self.assertEqual(self.run_kernel("install", "prov").returncode, 0)
189
+ self.pkg_dir("needr", caps="needs: [owfa.org.v1]")
190
+ self.assertEqual(self.install_path("needr").returncode, 0)
191
+ # bump the provider: v0.2 still provides owfa.org.v1
192
+ lines = (repo / "PKG.yaml").read_text(encoding="utf-8").replace("version: 0.1", "version: 0.2")
193
+ (repo / "PKG.yaml").write_text(lines, encoding="utf-8")
194
+ self.git(repo, "add", "-A")
195
+ self.git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "bump")
196
+ self.git(repo, "tag", "prov-v0.2")
197
+ self.index([("prov", f"repo: {repo}, version: 0.2, trust: reviewed-3rd-party, members: prov")])
198
+ # mid-upgrade the disk briefly lacks the sole provider — the prospective
199
+ # board (installed − old provides + new provides) must let it through
200
+ result = self.run_kernel("upgrade", "prov", "--yes")
201
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
202
+ self.assertIn("0.1 -> v0.2", result.stdout)
203
+ self.assertEqual(self.run_kernel("validate").returncode, 0)
204
+
205
+ # ---- INDEX extension ----
206
+
207
+ def test_index_parses_needs_provides_commit_and_strips_single_quotes(self):
208
+ self.index([
209
+ ("prov", "repo: 'https://x/y.git', version: 0.1, trust: 'reviewed-3rd-party', "
210
+ "members: a/b, needs: owfa.org.v1/qq.im.v1, provides: prov.org.v1/owfa.net.v1, commit: 'ABC123'"),
211
+ ("bare", 'repo: "https://x/z.git", version: 0.2, trust: framework-bundled, members: z'),
212
+ ])
213
+ prefix = "import sys; sys.path.insert(0, '.owfa/scripts'); import owfa_kernel as k; "
214
+ code = ("e,s=k.load_index([]); import json; "
215
+ "print(json.dumps({'trust': e['prov']['trust'], 'commit': e['prov'].get('commit'), "
216
+ "'needs': k._cap_list(e['prov'].get('needs')), "
217
+ "'provides': k._cap_list(e['prov'].get('provides')), "
218
+ "'bare_needs': k._cap_list(e['bare'].get('needs')), "
219
+ "'bare_commit': e['bare'].get('commit')}))")
220
+ r = subprocess.run([sys.executable, "-c", prefix + code], cwd=self.root,
221
+ env=self.env, capture_output=True, text=True, encoding="utf-8")
222
+ self.assertEqual(r.returncode, 0, r.stderr)
223
+ data = json.loads(r.stdout)
224
+ self.assertEqual(data["trust"], "reviewed-3rd-party") # single quotes stripped
225
+ self.assertEqual(data["commit"], "ABC123")
226
+ self.assertEqual(data["needs"], ["owfa.org.v1", "qq.im.v1"]) # '/'-separated
227
+ self.assertEqual(data["provides"], ["prov.org.v1", "owfa.net.v1"])
228
+ self.assertEqual(data["bare_needs"], []) # absent = empty set
229
+ self.assertIsNone(data["bare_commit"]) # absent = no pin
230
+
231
+ def test_commit_pin_mismatch_is_refused(self):
232
+ repo = self.git_pkg("prov", "0.1", provides=["owfa.org.v1"])
233
+ self.index([("prov", f"repo: {repo}, version: 0.1, trust: reviewed-3rd-party, members: prov, "
234
+ f"commit: {'0' * 40}")])
235
+ result = self.run_kernel("install", "prov")
236
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
237
+ self.assertIn("commit pin mismatch", result.stdout)
238
+ self.assertFalse((self.root / ".owfa/plugins/domains/probe").exists())
239
+
240
+ def test_commit_pin_match_installs(self):
241
+ repo = self.git_pkg("prov", "0.1", provides=["owfa.org.v1"])
242
+ self.index([("prov", f"repo: {repo}, version: 0.1, trust: reviewed-3rd-party, members: prov, "
243
+ f"commit: {self.git_head(repo)}")])
244
+ result = self.run_kernel("install", "prov")
245
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
246
+
247
+ # ---- suite kind ----
248
+
249
+ def test_rootless_suite_validates_green(self):
250
+ # rootless suite: parallel members, no root plugin — family-root/closure
251
+ # laws must not apply (the owfa-personal shape; id "synth" — the real
252
+ # "personal" suite is already installed by the v0.4 bootstrap setUp)
253
+ suite = self.source / "synthpkg"
254
+ suite.mkdir()
255
+ for name, deps in (("swiki", "[]"), ("slog", "[swiki]")):
256
+ member = suite / name
257
+ member.mkdir()
258
+ dependencies = ("depends: []\n" if deps == "[]" else "depends:\n - swiki\n")
259
+ (member / "PLUGIN.yaml").write_text(
260
+ f"id: {name}\nschema_version: 1\nversion: 0.1\nupdated: 2026-11-12\n"
261
+ f"{dependencies}attachment: []\nfields:\n"
262
+ f'inject: "{name} suite member (synthetic)"\n', encoding="utf-8")
263
+ (member / "PLUGIN.md").write_text("# member\n", encoding="utf-8")
264
+ (suite / "PKG.yaml").write_text(
265
+ "id: synth\nkind: suite\nversion: 0.1.0\ncompat: '>= 0.3'\n"
266
+ "members: [swiki, slog]\ndepends: [framework]\n", encoding="utf-8")
267
+ result = self.run_kernel("install", "synth", "--path", str(suite))
268
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
269
+ self.assertEqual(self.run_kernel("validate").returncode, 0)
270
+ # the same roster WITHOUT kind: suite must fail the family-root laws
271
+ (suite / "PKG.yaml").write_text(
272
+ "id: synth\nversion: 0.1.0\ncompat: '>= 0.3'\n"
273
+ "members: [swiki, slog]\ndepends: [framework]\n", encoding="utf-8")
274
+ self.run_kernel("uninstall", "synth", "--yes")
275
+ result = self.run_kernel("install", "synth", "--path", str(suite))
276
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
277
+ self.assertIn("family root synth must be a member", result.stdout)
278
+
279
+ def test_unknown_kind_is_rejected(self):
280
+ suite = self.source / "weirdpkg"
281
+ suite.mkdir()
282
+ self.manifest("weird", "weird member", parent=suite)
283
+ (suite / "PKG.yaml").write_text(
284
+ "id: weird\nkind: platform\nversion: 0.1\ncompat: '>= 0.3'\n"
285
+ "members: [weird]\ndepends: [framework]\n", encoding="utf-8")
286
+ result = self.run_kernel("install", "weird", "--path", str(suite))
287
+ self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
288
+ self.assertIn("kind (platform) must be 'suite' or 'domain-family'", result.stdout)
289
+
290
+ # ---- provides command (instance view) ----
291
+
292
+ def test_kernel_provides_reports_satisfaction(self):
293
+ self.pkg_dir("prov", caps="provides: [owfa.org.v1, prov.util.v1]")
294
+ self.assertEqual(self.install_path("prov").returncode, 0)
295
+ self.pkg_dir("needr", caps="needs: [owfa.org.v1]")
296
+ self.assertEqual(self.install_path("needr", "--yes").returncode, 0)
297
+ result = self.run_kernel("provides")
298
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
299
+ self.assertIn("pkg prov: provides owfa.org.v1, prov.util.v1", result.stdout)
300
+ # unverified (--path) provides are annotated as such
301
+ self.assertIn("trust: unknown — unverified provides", result.stdout)
302
+ self.assertIn("needs owfa.org.v1 — satisfied by UNVERIFIED provides", result.stdout)
303
+ # uninstall is not a capability convergence point: the need goes unsatisfied
304
+ # (red validate) while the uninstall itself succeeds — the instance view reports it
305
+ self.assertEqual(self.run_kernel("uninstall", "prov", "--yes").returncode, 0)
306
+ result = self.run_kernel("provides")
307
+ self.assertIn("needs owfa.org.v1 — NOT satisfied: capability missing entirely", result.stdout)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ unittest.main()
@@ -1,6 +1,6 @@
1
1
  """Offline install regressions; synthetic packages and disposable instances only.
2
2
 
3
- Run: python -m unittest discover -s .meta/tests -p test_install_preflight.py -v
3
+ Run: python -m unittest discover -s .owfa/tests -p test_install_preflight.py -v
4
4
  """
5
5
  import hashlib
6
6
  import json
@@ -22,7 +22,7 @@ class InstallPreflightTests(unittest.TestCase):
22
22
  self.addCleanup(self.temp.cleanup)
23
23
  self.root = Path(self.temp.name) / "instance"
24
24
  self.root.mkdir()
25
- for directory in (".meta", ".agents"):
25
+ for directory in (".owfa", ".agents"):
26
26
  shutil.copytree(FRAMEWORK / directory, self.root / directory,
27
27
  ignore=shutil.ignore_patterns("__pycache__", "tests", "smoke-tmp"))
28
28
  (self.root / "AGENTS.md").write_text(
@@ -32,16 +32,20 @@ class InstallPreflightTests(unittest.TestCase):
32
32
  self.source.mkdir()
33
33
  self.env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", PYTHONIOENCODING="utf-8")
34
34
  self.env.pop("PYTHONPATH", None)
35
+ # v0.4 final shape: the framework tree ships no plugins — the synthetic
36
+ # instances bootstrap exactly like a real init (embedded personal suite)
37
+ result = self.run_kernel("install", "personal", "--path", str(FRAMEWORK / "payloads" / "personal"))
38
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
35
39
  result = self.run_kernel("all")
36
40
  self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
37
41
 
38
42
  def run_kernel(self, *args):
39
43
  return subprocess.run(
40
- [sys.executable, str(self.root / ".meta/scripts/wiki_plugin_kernel.py"), *args],
44
+ [sys.executable, str(self.root / ".owfa/scripts/owfa_kernel.py"), *args],
41
45
  cwd=self.root, env=self.env, capture_output=True, text=True, encoding="utf-8")
42
46
 
43
47
  def run_code(self, code):
44
- prefix = "import sys; sys.path.insert(0, '.meta/scripts'); import wiki_plugin_kernel as k; "
48
+ prefix = "import sys; sys.path.insert(0, '.owfa/scripts'); import owfa_kernel as k; "
45
49
  return subprocess.run([sys.executable, "-c", prefix + code], cwd=self.root,
46
50
  env=self.env, capture_output=True, text=True, encoding="utf-8")
47
51
 
@@ -87,7 +91,7 @@ class InstallPreflightTests(unittest.TestCase):
87
91
  path = self.source / relative
88
92
  path.parent.mkdir(parents=True, exist_ok=True)
89
93
  path.write_text("Synthetic payload", encoding="utf-8")
90
- ledger = self.root / ".meta/migrations/pending.json"
94
+ ledger = self.root / ".owfa/migrations/pending.json"
91
95
  ledger.parent.mkdir(exist_ok=True)
92
96
  ledger.write_text(json.dumps({"probe": {"kind": "teardown", "items": []}}), encoding="utf-8")
93
97
  before = self.snapshot()
@@ -112,7 +116,7 @@ class InstallPreflightTests(unittest.TestCase):
112
116
  self.manifest("child", "界" * 6000, depends="[optional]")
113
117
  result = self.install("--without", "optional")
114
118
  self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
115
- installed = self.root / ".meta/plugins/domains/probe"
119
+ installed = self.root / ".owfa/plugins/domains/probe"
116
120
  self.assertFalse((installed / "optional").exists())
117
121
  self.assertFalse((installed / "child").exists())
118
122
  self.assertEqual(self.run_kernel("validate").returncode, 0)
@@ -141,7 +145,7 @@ class InstallPreflightTests(unittest.TestCase):
141
145
  self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
142
146
  self.assertIn("install incomplete", result.stdout)
143
147
  self.assertIn("missing command", result.stdout)
144
- self.assertTrue((self.root / ".meta/plugins/domains/probe/PKG.yaml").exists())
148
+ self.assertTrue((self.root / ".owfa/plugins/domains/probe/PKG.yaml").exists())
145
149
 
146
150
  def test_projection_failure_returns_nonzero(self):
147
151
  self.package()
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """owfa_home unit tests: resolution order, bind/unbind, exclusivity, stale, migration.
4
+
5
+ Run: python3 .owfa/tests/test_owfa_home.py (pure stdlib; OWFA_HOME pointed at a temp dir)
6
+ """
7
+ import contextlib
8
+ import io
9
+ import os
10
+ import stat
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
17
+ import owfa_home as h # noqa: E402
18
+
19
+
20
+ class OwfaHomeTests(unittest.TestCase):
21
+ def setUp(self):
22
+ self._cwd = os.getcwd()
23
+ self.tmp = tempfile.TemporaryDirectory()
24
+ self.root = Path(self.tmp.name)
25
+ os.environ["OWFA_HOME"] = str(self.root / "machine")
26
+ self.ws1 = self._ws("ws1")
27
+ self.ws2 = self._ws("ws2")
28
+ os.environ.pop("OWFA_ROOT", None)
29
+
30
+ def tearDown(self):
31
+ # tests chdir into the temp workspace; TemporaryDirectory.cleanup()
32
+ # cannot delete the cwd, so restore first (review P3, PR #57 round 2)
33
+ os.chdir(self._cwd)
34
+ self.tmp.cleanup()
35
+ os.environ.pop("OWFA_HOME", None)
36
+ os.environ.pop("OWFA_ROOT", None)
37
+
38
+ def _ws(self, name):
39
+ p = self.root / name
40
+ p.mkdir(parents=True)
41
+ return str(p)
42
+
43
+ def test_unmatched_cwd_on_multi_binding_machine_is_loud(self):
44
+ # review round 3: falling back to homes/default silently on a
45
+ # multi-binding machine invents an unregistered home two unmatched
46
+ # workspaces could share — still served (degraded-but-healthy), but loud
47
+ h.bind("qq", self.ws1, "alpha")
48
+ h.bind("qq", self.ws2, "beta")
49
+ os.chdir(self._ws("ws3"))
50
+ err = io.StringIO()
51
+ with contextlib.redirect_stderr(err):
52
+ home = h.resolve_home("qq")
53
+ self.assertEqual(home, h.home_dir("qq", "default"))
54
+ self.assertIn("matches none", err.getvalue())
55
+
56
+ @unittest.skipIf(sys.platform == "win32", "mkdir mode is a POSIX no-op on Windows")
57
+ def test_bind_home_dir_is_0700(self):
58
+ # review round 3: docs/home.md promises 0600/0700 for connector homes
59
+ entry = h.bind("qq", self.ws1, "alpha")
60
+ self.assertEqual(stat.S_IMODE(os.stat(entry["home"]).st_mode), 0o700)
61
+
62
+ def test_default_home_is_zero_config(self):
63
+ os.chdir(self.ws1)
64
+ home = h.resolve_home("qq-cli")
65
+ self.assertEqual(home, self.root / "machine" / "connectors" / "qq-cli" / "homes" / "default")
66
+
67
+ def test_explicit_root_binding_wins(self):
68
+ os.chdir(self.ws1)
69
+ h.bind("qq-cli", self.ws1, "personal", bot="appid-1")
70
+ self.assertEqual(h.resolve_home("qq-cli"), Path(h.home_dir("qq-cli", "personal")))
71
+ entry = h.load_bindings("qq-cli")[0]
72
+ self.assertEqual(entry["bot"], "appid-1")
73
+ self.assertTrue(os.path.isdir(entry["home"]), "bind must create the home dir")
74
+
75
+ def test_resolution_order_env_then_cwd(self):
76
+ h.bind("qq-cli", self.ws1, "a")
77
+ h.bind("qq-cli", self.ws2, "b")
78
+ os.environ["OWFA_ROOT"] = self.ws2
79
+ self.assertEqual(h.resolve_home("qq-cli"), Path(h.home_dir("qq-cli", "b")))
80
+ os.environ.pop("OWFA_ROOT", None)
81
+ os.chdir(self.ws1)
82
+ self.assertEqual(h.resolve_home("qq-cli"), Path(h.home_dir("qq-cli", "a"))) # cwd lookup
83
+ self.assertEqual(h.resolve_home("qq-cli", explicit_root=self.ws2), Path(h.home_dir("qq-cli", "b"))) # explicit wins
84
+
85
+ def test_rebind_same_root_replaces(self):
86
+ h.bind("qq-cli", self.ws1, "first")
87
+ h.bind("qq-cli", self.ws1) # no id: re-binds under the existing id for this root
88
+ bindings = h.load_bindings("qq-cli")
89
+ self.assertEqual(len(bindings), 1)
90
+ self.assertEqual(bindings[0]["id"], "first")
91
+
92
+ def test_unbind_detaches_but_keeps_home(self):
93
+ b = h.bind("qq-cli", self.ws1, "keep-data")
94
+ h.unbind("qq-cli", "keep-data")
95
+ self.assertEqual(h.load_bindings("qq-cli"), [])
96
+ self.assertTrue(os.path.isdir(b["home"]), "unbind must not delete home data")
97
+
98
+ def test_exclusivity_violation_detected(self):
99
+ home = h.home_dir("qq-cli", "shared")
100
+ for ws, bid in ((self.ws1, "one"), (self.ws2, "two")):
101
+ b = h.bind("qq-cli", ws, bid)
102
+ b["home"] = str(home) # force two bindings onto one home
103
+ h.save_bindings("qq-cli", [x for x in h.load_bindings("qq-cli") if x["id"] != bid] + [b])
104
+ v = h.exclusivity_violations("qq-cli")
105
+ self.assertEqual(len(v), 1)
106
+ self.assertEqual(sorted(v[0]["bindings"]), ["one", "two"])
107
+
108
+ def test_stale_bindings_reported(self):
109
+ gone = self._ws("gone")
110
+ h.bind("qq-cli", gone, "dying")
111
+ os.rmdir(gone)
112
+ stale = h.stale_bindings("qq-cli")
113
+ self.assertEqual([s["id"] for s in stale], ["dying"])
114
+
115
+ def test_migrate_moves_and_symlinks(self):
116
+ legacy = Path(self._ws("legacy-home"))
117
+ (legacy / "credentials.json").write_text("{}", encoding="utf-8")
118
+ os.chdir(self.ws1)
119
+ entry = h.migrate_legacy_home("qq-cli", str(legacy), "default", root=self.ws1)
120
+ self.assertFalse(legacy.exists() and not legacy.is_symlink(), "legacy dir must move, not copy")
121
+ self.assertTrue((Path(entry["home"]) / "credentials.json").exists(), "contents must land in the new home")
122
+ self.assertTrue(legacy.is_symlink() and os.path.realpath(legacy) == os.path.realpath(entry["home"]))
123
+ self.assertEqual(os.path.basename(os.path.realpath(legacy)), "default")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main(verbosity=2)
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Version-scheme unit tests (versioning scheme, 0.4): three-part anchor parse,
4
+ B-line extraction, and the migration staging range [prev_line, new_line).
5
+
6
+ Run: python3 -m unittest discover -s .owfa/tests -p test_version_scheme.py
7
+ """
8
+ import os
9
+ import re
10
+ import sys
11
+ import unittest
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts"))
14
+ import owfa_kernel as k # noqa: E402
15
+
16
+
17
+ class VersionSchemeTests(unittest.TestCase):
18
+ def test_framework_version_parses_three_part_anchor(self):
19
+ # the repo's own VERSION is the full release version now
20
+ self.assertEqual(k.framework_version(), (0, 4, 0))
21
+
22
+ def test_framework_version_tolerates_legacy_two_part(self):
23
+ import tempfile
24
+ vpath = os.path.join(k.ROOT, ".owfa", "VERSION")
25
+ original = open(vpath, encoding="utf-8").read()
26
+ try:
27
+ open(vpath, "w", encoding="utf-8").write("0.3\n")
28
+ self.assertEqual(k.framework_version(), (0, 3))
29
+ finally:
30
+ open(vpath, "w", encoding="utf-8").write(original)
31
+
32
+ def test_vline_extracts_major_minor(self):
33
+ self.assertEqual(k._vline("0.3.0"), (0, 3))
34
+ self.assertEqual(k._vline("0.3.11"), (0, 3))
35
+ self.assertEqual(k._vline("0.2"), (0, 2))
36
+
37
+ def test_compat_floor_tuple_semantics(self):
38
+ fw = k.framework_version() # (0, 4, 0)
39
+ self.assertFalse((0, 3) > fw) # floor below the line passes
40
+ self.assertFalse((0, 4) > fw) # floor at the line passes
41
+ self.assertFalse((0, 4, 0) > fw) # floor equal to the release passes
42
+ self.assertTrue((0, 4, 1) > fw) # floor demanding a later patch refuses
43
+ self.assertTrue((0, 5) > fw) # floor above the line refuses
44
+
45
+ def test_staging_line_range(self):
46
+ # mirror of the _stage_migrations predicate: stage when prev_line <= from_line < new_line
47
+ def stages(name, prev, new):
48
+ m = re.fullmatch(r"(\d+\.\d+)-to-(\d+\.\d+)\.md", name)
49
+ if not m:
50
+ return False
51
+ prev_line, new_line = k._vline(prev), k._vline(new)
52
+ return prev_line <= k._vt(m.group(1)) < new_line
53
+
54
+ self.assertTrue(stages("0.3-to-0.4.md", "0.3.2", "0.4.0")) # line crossing stages
55
+ self.assertTrue(stages("0.3-to-0.4.md", "0.3", "0.4.0")) # legacy two-part prev works
56
+ self.assertTrue(stages("0.2-to-0.3.md", "0.2", "0.4.0")) # chained crossings stage together
57
+ self.assertFalse(stages("0.3-to-0.4.md", "0.3.1", "0.3.2")) # same-line C update stages nothing
58
+ self.assertFalse(stages("0.2-to-0.3.md", "0.3", "0.4.0")) # already past does not re-stage
59
+ self.assertFalse(stages("0.4-to-0.5.md", "0.3", "0.4.0")) # future lines do not stage
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()