Skip to content

Commit ef72720

Browse files
yjwyjw
authored andcommitted
fix: version-gate stock analysis specifications
1 parent fa3ff6a commit ef72720

7 files changed

Lines changed: 161 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.2] - 2026-06-18
9+
10+
### Fixed
11+
- Made stock-analysis updates strictly version-gated: remote content is installed only when its semantic version is greater than the verified local version.
12+
- Downloaded the reporting specification, output discipline, data-source strategy, M1-M6 methodology, and templates as one text-only bundle.
13+
- Added SHA-256 manifests and read-time validation for the cached methodology bundle; failures retain the last verified local or bundled specification.
14+
- Kept the security boundary explicit: no remote Python, JavaScript, shell code, package, or repository checkout is executed by report generation.
15+
816
## [0.2.1] - 2026-06-18
917

1018
### Added

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,12 @@ The report follows the six-module method from
219219
5. M5 market/portfolio style
220220
6. M6 resilient directions
221221

222-
Before each LLM replay, young checks the remote `stock-analysis` `SKILL.md`. A newer text specification is cached
223-
under `~/.young_stock/methodologies/stock-analysis/` and used for report structure and writing discipline. Remote
224-
code is never executed; if the check is unavailable, the most recent cached specification or the bundled 4.2.0
225-
guidance is used.
222+
Before each LLM replay, young checks the remote `stock-analysis` version. It installs a text-only update only when
223+
the remote semantic version is greater than the locally cached/bundled version. `SKILL.md`, output discipline,
224+
data-source strategy, M1-M6 methodology files, and report templates are downloaded together, SHA-256 recorded in
225+
`~/.young_stock/methodologies/stock-analysis/manifest.json`, and verified when read. Remote code is never executed.
226+
If checking, downloading, or validation fails, young keeps the last verified local specification or bundled 4.2.0
227+
guidance.
226228

227229
Each module has an evidence score. Missing fields remain missing rather than being rendered as zero. Low-quality
228230
evidence automatically produces a shorter report limited to verified indices, holdings, risks, and next-session

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "young-stock-cli"
7-
version = "0.2.1"
7+
version = "0.2.2"
88
description = "A-share (China stock market) after-hours CLI — no login, no scraping tricks, just data."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/young_stock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""young-stock-cli: A-share after-hours CLI."""
22

3-
__version__ = "0.2.1"
3+
__version__ = "0.2.2"

src/young_stock/methodology.py

Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
from __future__ import annotations
44

5+
import hashlib
6+
import json
7+
import os
58
import re
9+
import shutil
10+
import tempfile
611
from dataclasses import dataclass
712
from pathlib import Path
813
from typing import Any
@@ -15,6 +20,21 @@
1520
"https://raw.githubusercontent.com/AdvancingTitans/stock-analysis/main/"
1621
"skills/stock-analysis/SKILL.md"
1722
)
23+
STOCK_ANALYSIS_RAW_BASE = STOCK_ANALYSIS_SKILL_URL.rsplit("/", 1)[0]
24+
REFERENCE_PATHS = (
25+
"references/output_discipline.md",
26+
"references/data-source-strategy.md",
27+
"references/analysis-template.md",
28+
"references/methodology/m1-index-overview.md",
29+
"references/methodology/m2-sector-flow.md",
30+
"references/methodology/m3-upside.md",
31+
"references/methodology/m4-downside.md",
32+
"references/methodology/m5-style-buckets.md",
33+
"references/methodology/m6-resilient.md",
34+
"references/template/analysis-template.md",
35+
"references/template/module-template.md",
36+
"references/template/portfolio-template.md",
37+
)
1838
BUILTIN_VERSION = "4.2.0"
1939
BUILTIN_GUIDANCE = """stock-analysis 4.2.0:
2040
- 固定顺序:大盘指数概览、持仓分析、六模块深度复盘、综合持仓建议与风险提示。
@@ -37,25 +57,99 @@ def _version(text: str) -> str:
3757
return match.group(1) if match else BUILTIN_VERSION
3858

3959

60+
def _version_tuple(value: str) -> tuple[int, ...]:
61+
parts = re.findall(r"\d+", value)
62+
return tuple(int(part) for part in parts[:4]) or (0,)
63+
64+
4065
def _cache_path() -> Path:
4166
return young_home() / "methodologies" / "stock-analysis" / "SKILL.md"
4267

4368

69+
def _sha256(text: str) -> str:
70+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
71+
72+
73+
def _cached_spec(path: Path) -> tuple[str, str]:
74+
if not path.exists():
75+
return BUILTIN_VERSION, BUILTIN_GUIDANCE
76+
skill_text = path.read_text(encoding="utf-8")
77+
manifest_path = path.parent / "manifest.json"
78+
if not manifest_path.exists():
79+
return _version(skill_text), skill_text
80+
try:
81+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
82+
checksums = manifest.get("sha256") or {}
83+
texts = {}
84+
for relative, expected in checksums.items():
85+
candidate = path.parent / relative
86+
content = candidate.read_text(encoding="utf-8")
87+
if _sha256(content) != expected:
88+
raise ValueError("checksum mismatch")
89+
texts[relative] = content
90+
except (OSError, ValueError, json.JSONDecodeError):
91+
return BUILTIN_VERSION, BUILTIN_GUIDANCE
92+
combined = [texts.get("SKILL.md", skill_text)]
93+
combined.extend(texts[relative] for relative in REFERENCE_PATHS if relative in texts)
94+
return str(manifest.get("version") or _version(skill_text)), "\n\n".join(combined)
95+
96+
97+
def _download_text(client: Any, url: str, timeout: float) -> str:
98+
response = client.get(url, timeout=timeout)
99+
if response.status_code >= 400 or not str(response.text).strip():
100+
raise RuntimeError(f"HTTP {response.status_code}")
101+
return str(response.text).rstrip() + "\n"
102+
103+
104+
def _install_spec(path: Path, version: str, files: dict[str, str]) -> None:
105+
parent = path.parent.parent
106+
parent.mkdir(parents=True, exist_ok=True)
107+
with tempfile.TemporaryDirectory(prefix="stock-analysis-", dir=parent) as temp_name:
108+
temp_root = Path(temp_name)
109+
for relative, content in files.items():
110+
target = temp_root / relative
111+
target.parent.mkdir(parents=True, exist_ok=True)
112+
target.write_text(content, encoding="utf-8")
113+
manifest = {
114+
"version": version,
115+
"sha256": {relative: _sha256(content) for relative, content in files.items()},
116+
}
117+
(temp_root / "manifest.json").write_text(
118+
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
119+
encoding="utf-8",
120+
)
121+
destination = path.parent
122+
backup = destination.with_name(destination.name + ".previous")
123+
if backup.exists():
124+
shutil.rmtree(backup)
125+
if destination.exists():
126+
os.replace(destination, backup)
127+
os.replace(temp_root, destination)
128+
if backup.exists():
129+
shutil.rmtree(backup)
130+
131+
44132
def sync_stock_analysis_methodology(*, session: Any = None, timeout: float = 5) -> MethodologySpec:
45133
path = _cache_path()
46-
current_text = path.read_text(encoding="utf-8") if path.exists() else BUILTIN_GUIDANCE
47-
current_version = _version(current_text)
134+
current_version, current_text = _cached_spec(path)
48135
client = session or requests.Session()
49136
try:
50-
response = client.get(STOCK_ANALYSIS_SKILL_URL, timeout=timeout)
51-
if response.status_code >= 400 or not str(response.text).strip():
52-
raise RuntimeError(f"HTTP {response.status_code}")
53-
remote_text = str(response.text)
137+
remote_skill = _download_text(client, STOCK_ANALYSIS_SKILL_URL, timeout)
138+
except Exception:
139+
return MethodologySpec(current_version, current_text, path, updated=False)
140+
remote_version = _version(remote_skill)
141+
if _version_tuple(remote_version) <= _version_tuple(current_version):
142+
return MethodologySpec(current_version, current_text, path, updated=False)
143+
files = {"SKILL.md": remote_skill}
144+
try:
145+
for relative in REFERENCE_PATHS:
146+
files[relative] = _download_text(
147+
client,
148+
f"{STOCK_ANALYSIS_RAW_BASE}/{relative}",
149+
timeout,
150+
)
151+
_install_spec(path, remote_version, files)
54152
except Exception:
55153
return MethodologySpec(current_version, current_text, path, updated=False)
56-
remote_version = _version(remote_text)
57-
updated = remote_text != current_text
58-
if updated:
59-
path.parent.mkdir(parents=True, exist_ok=True)
60-
path.write_text(remote_text.rstrip() + "\n", encoding="utf-8")
61-
return MethodologySpec(remote_version, remote_text, path, updated=updated)
154+
combined = [files["SKILL.md"], *(files[relative] for relative in REFERENCE_PATHS)]
155+
return MethodologySpec(remote_version, "\n\n".join(combined), path, updated=True)

tests/test_methodology.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
1+
import json
12
from types import SimpleNamespace
23

3-
from young_stock.methodology import sync_stock_analysis_methodology
4+
from young_stock.methodology import REFERENCE_PATHS, sync_stock_analysis_methodology
45

56

67
class FakeSession:
8+
def __init__(self, version="4.3.0"):
9+
self.version = version
10+
self.urls = []
11+
712
def get(self, url, timeout):
13+
self.urls.append(url)
14+
if url.endswith("SKILL.md"):
15+
text = f'---\nname: stock-analysis\nmetadata:\n version: "{self.version}"\n---\n# 新版规范\n'
16+
else:
17+
text = f"# {url.rsplit('/', 1)[-1]}\n\n新版模板内容\n"
818
return SimpleNamespace(
919
status_code=200,
10-
text='---\nname: stock-analysis\nmetadata:\n version: "4.3.0"\n---\n# 新版规范\n',
20+
text=text,
1121
)
1222

1323

1424
def test_sync_stock_analysis_methodology_caches_new_remote_version(monkeypatch, tmp_path):
1525
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
26+
session = FakeSession()
1627

17-
result = sync_stock_analysis_methodology(session=FakeSession())
28+
result = sync_stock_analysis_methodology(session=session)
1829

1930
assert result.version == "4.3.0"
2031
assert result.updated is True
2132
assert result.path.read_text(encoding="utf-8").endswith("# 新版规范\n")
33+
assert len(session.urls) == 1 + len(REFERENCE_PATHS)
34+
manifest = json.loads((result.path.parent / "manifest.json").read_text(encoding="utf-8"))
35+
assert manifest["version"] == "4.3.0"
36+
assert set(manifest["sha256"]) == {"SKILL.md", *REFERENCE_PATHS}
37+
assert "新版模板内容" in result.text
38+
39+
40+
def test_sync_does_not_replace_newer_local_version(monkeypatch, tmp_path):
41+
monkeypatch.setenv("YOUNG_STOCK_HOME", str(tmp_path))
42+
local = tmp_path / "methodologies" / "stock-analysis"
43+
local.mkdir(parents=True)
44+
(local / "SKILL.md").write_text(
45+
'---\nmetadata:\n version: "4.4.0"\n---\n# 本地新版\n',
46+
encoding="utf-8",
47+
)
48+
session = FakeSession(version="4.3.0")
49+
50+
result = sync_stock_analysis_methodology(session=session)
51+
52+
assert result.version == "4.4.0"
53+
assert result.updated is False
54+
assert result.path.read_text(encoding="utf-8").endswith("# 本地新版\n")
55+
assert len(session.urls) == 1

tests/test_packaging_docs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ def test_package_version_is_next_patch_release():
2020
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
2121
init_py = (ROOT / "src" / "young_stock" / "__init__.py").read_text(encoding="utf-8")
2222

23-
assert 'version = "0.2.1"' in pyproject
23+
assert 'version = "0.2.2"' in pyproject
2424
assert 'requires-python = ">=3.9"' in pyproject
2525
assert '"Programming Language :: Python :: 3.9"' in pyproject
26-
assert '__version__ = "0.2.1"' in init_py
26+
assert '__version__ = "0.2.2"' in init_py
2727

2828

2929
def test_pdf_template_is_declared_as_package_data():

0 commit comments

Comments
 (0)