@hupan56/wlkj 3.1.31 → 3.2.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.
- package/package.json +1 -1
- package/templates/qoder/agents/insight-planning.md +67 -67
- package/templates/qoder/agents/prd-reference.md +47 -47
- package/templates/qoder/commands/optional/wl-insight.md +276 -276
- package/templates/qoder/commands/optional/wl-spec.md +10 -2
- package/templates/qoder/commands/wl-code.md +63 -4
- package/templates/qoder/commands/wl-design.md +1 -1
- package/templates/qoder/commands/wl-prd.md +87 -13
- package/templates/qoder/commands/wl-req.md +10 -3
- package/templates/qoder/commands/wl-task.md +613 -613
- package/templates/qoder/commands/wl-test.md +2 -2
- package/templates/qoder/contracts/CHANGELOG.md +418 -0
- package/templates/qoder/contracts/README.md +180 -0
- package/templates/qoder/contracts/code.md +82 -0
- package/templates/qoder/contracts/commit.md +86 -0
- package/templates/qoder/contracts/contract-header.md +76 -0
- package/templates/qoder/contracts/design.md +106 -0
- package/templates/qoder/contracts/fallback.md +126 -0
- package/templates/qoder/contracts/isolation.md +119 -0
- package/templates/qoder/contracts/prd.md +118 -0
- package/templates/qoder/contracts/schemas/design-spec.schema.json +46 -0
- package/templates/qoder/contracts/schemas/prd.schema.json +36 -0
- package/templates/qoder/contracts/schemas/test-cases.schema.json +40 -0
- package/templates/qoder/contracts/spec.md +112 -0
- package/templates/qoder/contracts/task.md +125 -0
- package/templates/qoder/contracts/test.md +112 -0
- package/templates/qoder/hooks/post-tool-use.py +20 -0
- package/templates/qoder/hooks/stop-eval.py +47 -0
- package/templates/qoder/rules/wl-pipeline.md +37 -0
- package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +11 -0
- package/templates/qoder/scripts/deployment/setup/setup.py +70 -0
- package/templates/qoder/scripts/deployment/setup/wlkj_shim.py +104 -0
- package/templates/qoder/scripts/domain/deployment/deploy_to_test.py +298 -0
- package/templates/qoder/scripts/domain/kg/build/kg_build.py +241 -22
- package/templates/qoder/scripts/domain/kg/build/kg_incremental.py +108 -3
- package/templates/qoder/scripts/domain/kg/build/kg_signatures.py +169 -0
- package/templates/qoder/scripts/domain/kg/extract/ts_extract.py +111 -0
- package/templates/qoder/scripts/domain/kg/storage/kg_duckdb.py +43 -0
- package/templates/qoder/scripts/domain/requirement/req.py +134 -28
- package/templates/qoder/scripts/domain/task/zentao_panel.py +688 -53
- package/templates/qoder/scripts/foundation/core/paths.py +102 -0
- package/templates/qoder/scripts/protocol/mcp/zentao_mcp_server.py +23 -10
- package/templates/qoder/scripts/validation/eval/qwork_harness.py +1 -1
- package/templates/qoder/scripts/validation/eval/report-commands.md +2 -2
- package/templates/qoder/settings.json +27 -9
- package/templates/qoder/skills/design-import/SKILL.md +3 -3
- package/templates/qoder/skills/design-review/SKILL.md +1 -1
- package/templates/qoder/skills/prd-generator/SKILL.md +4 -4
- package/templates/qoder/skills/prd-review/SKILL.md +1 -1
- package/templates/qoder/skills/prototype-generator/SKILL.md +3 -3
- package/templates/qoder/skills/spec-coder/SKILL.md +1 -1
- package/templates/qoder/skills/spec-generator/SKILL.md +80 -23
- package/templates/qoder/skills/test-generator/SKILL.md +1 -1
- package/templates/qoder/skills/wl-code/SKILL.md +13 -1
- package/templates/qoder/skills/wl-commit/SKILL.md +1 -1
- package/templates/qoder/skills/wl-design/SKILL.md +6 -6
- package/templates/qoder/skills/wl-init/SKILL.md +2 -2
- package/templates/qoder/skills/wl-insight/SKILL.md +5 -5
- package/templates/qoder/skills/wl-prd/SKILL.md +60 -0
- package/templates/qoder/skills/wl-report/SKILL.md +2 -2
- package/templates/qoder/skills/wl-search/SKILL.md +1 -1
- package/templates/qoder/skills/wl-spec/SKILL.md +2 -2
- package/templates/qoder/skills/wl-status/SKILL.md +2 -2
- package/templates/qoder/skills/wl-task/SKILL.md +3 -3
- package/templates/qoder/skills/wl-test/SKILL.md +2 -2
- package/templates/qoder/templates/spec-template.md +124 -0
- package/templates/root/AGENTS.md +32 -4
- package/templates/qoder/skills/wl-prd-full/SKILL.md +0 -121
- package/templates/qoder/skills/wl-prd-quick/SKILL.md +0 -50
- package/templates/qoder/skills/wl-prd-review/SKILL.md +0 -47
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
|
|
3
|
+
import os as _o, sys as _s
|
|
4
|
+
_f = _o.path.abspath(__file__)
|
|
5
|
+
for _ in range(10):
|
|
6
|
+
_f = _o.path.dirname(_f)
|
|
7
|
+
_cp = _o.path.join(_f, 'foundation')
|
|
8
|
+
if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
|
|
9
|
+
break
|
|
10
|
+
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
11
|
+
from bootstrap import setup; setup()
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
File-signature based incremental change detection (P0: fingerprint 三级增量).
|
|
15
|
+
|
|
16
|
+
Goal: skip re-extraction for files whose changes don't affect the graph.
|
|
17
|
+
The knowledge graph only records *topology* (who clicks/calls/handles whom),
|
|
18
|
+
not implementations — so a file that only changed comments/whitespace has
|
|
19
|
+
zero impact on the graph and can be safely skipped.
|
|
20
|
+
|
|
21
|
+
Three levels (mirrors Understand-Anything's fingerprint approach, adapted to
|
|
22
|
+
our regex/topology-only graph):
|
|
23
|
+
- NONE : content_hash unchanged → file is byte-identical, skip entirely.
|
|
24
|
+
- COSMETIC : structural_hash unchanged but content_hash changed
|
|
25
|
+
→ only comments/whitespace differ, skip re-extraction.
|
|
26
|
+
- STRUCTURAL : structural_hash changed → real code changed, re-extract.
|
|
27
|
+
|
|
28
|
+
The signature is intentionally coarse (strip comments + all whitespace, hash
|
|
29
|
+
the rest) — language-agnostic and robust. Worst case (e.g. pure variable
|
|
30
|
+
rename) degrades to STRUCTURAL = re-extract = current behaviour, never wrong.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Comment stripping (language-aware, single regex each) ────────────────────
|
|
39
|
+
# Block comments /* ... */ and HTML comments <!-- ... --> span both JS-family
|
|
40
|
+
# (.ts/.vue/.tsx/.jsx) and SQL (-- / /* */). Line comments vary by language.
|
|
41
|
+
|
|
42
|
+
# /* block */ and <!-- block --> — shared by JS-family + SQL + Vue template
|
|
43
|
+
_RE_BLOCK = re.compile(r'/\*.*?\*/', re.DOTALL)
|
|
44
|
+
_RE_HTML_BLOCK = re.compile(r'<!--.*?-->', re.DOTALL)
|
|
45
|
+
|
|
46
|
+
# // line comment — TS/JS/Vue script + Java. Robust against URL-ish tokens
|
|
47
|
+
# (only strips when // is at start of a token, not inside a string — see below).
|
|
48
|
+
_RE_LINE_SLASH = re.compile(r'(^|[\s;{}()\[\],])//[^\n]*')
|
|
49
|
+
|
|
50
|
+
# # line comment — Python / shell / YAML / .properties
|
|
51
|
+
_RE_LINE_HASH = re.compile(r'(^|[\s;{}()\[\],])#[^\n]*')
|
|
52
|
+
|
|
53
|
+
# -- line comment — SQL / XML DTD
|
|
54
|
+
_RE_LINE_DASH = re.compile(r'(^|[\s;{}()\[\],])--[^\n]*')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def strip_comments(content, suffix):
|
|
58
|
+
"""Remove comments from source text by file suffix.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
content: source text (str)
|
|
62
|
+
suffix: file suffix lowercased with dot, e.g. '.vue', '.ts', '.java'
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Text with comments removed. Block comments always stripped for
|
|
66
|
+
JS-family/SQL; line-comment style picked by language.
|
|
67
|
+
"""
|
|
68
|
+
s = content
|
|
69
|
+
# Block comments: JS-family + SQL share /* */; Vue/HTML share <!-- -->.
|
|
70
|
+
if suffix in ('.ts', '.tsx', '.js', '.jsx', '.vue', '.java', '.sql'):
|
|
71
|
+
s = _RE_BLOCK.sub(' ', s)
|
|
72
|
+
if suffix in ('.vue', '.html', '.xml'):
|
|
73
|
+
s = _RE_HTML_BLOCK.sub(' ', s)
|
|
74
|
+
# Line comments by language family.
|
|
75
|
+
if suffix in ('.ts', '.tsx', '.js', '.jsx', '.vue', '.java'):
|
|
76
|
+
s = _RE_LINE_SLASH.sub(r'\1', s)
|
|
77
|
+
elif suffix in ('.py', '.sh', '.yml', '.yaml', '.properties', '.cfg', '.ini'):
|
|
78
|
+
s = _RE_LINE_HASH.sub(r'\1', s)
|
|
79
|
+
elif suffix == '.sql':
|
|
80
|
+
s = _RE_LINE_DASH.sub(r'\1', s)
|
|
81
|
+
return s
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _strip_all_whitespace(s):
|
|
85
|
+
"""Remove every whitespace char so formatting/indentation never affects hash."""
|
|
86
|
+
return re.sub(r'\s+', '', s)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_hashes(filepath):
|
|
90
|
+
"""Compute (content_hash, structural_hash) for a file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
filepath: absolute or relative path to the source file.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
(content_hash, structural_hash) — both hex sha256, or (None, None) if
|
|
97
|
+
the file can't be read.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
with open(filepath, 'rb') as f:
|
|
101
|
+
raw = f.read()
|
|
102
|
+
except Exception:
|
|
103
|
+
return (None, None)
|
|
104
|
+
content_hash = hashlib.sha256(raw).hexdigest()
|
|
105
|
+
|
|
106
|
+
suffix = os.path.splitext(filepath)[1].lower()
|
|
107
|
+
try:
|
|
108
|
+
text = raw.decode('utf-8', errors='replace')
|
|
109
|
+
except Exception:
|
|
110
|
+
text = ''
|
|
111
|
+
stripped = strip_comments(text, suffix)
|
|
112
|
+
structural = _strip_all_whitespace(stripped)
|
|
113
|
+
structural_hash = hashlib.sha256(structural.encode('utf-8')).hexdigest()
|
|
114
|
+
return (content_hash, structural_hash)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def classify(old_sig, new_sig):
|
|
118
|
+
"""Compare old vs new (content_hash, structural_hash) → change level.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
old_sig: dict {content_hash, structural_hash}, or tuple of the two,
|
|
122
|
+
or None (never seen → STRUCTURAL).
|
|
123
|
+
new_sig: (content_hash, structural_hash) tuple from compute_hashes.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
'NONE' | 'COSMETIC' | 'STRUCTURAL'.
|
|
127
|
+
Never-seen files (old_sig is None) → 'STRUCTURAL' (safe default: extract).
|
|
128
|
+
"""
|
|
129
|
+
if old_sig is None:
|
|
130
|
+
return 'STRUCTURAL'
|
|
131
|
+
# Accept both dict (from get_file_signature) and tuple (from compute_hashes)
|
|
132
|
+
# so callers don't have to convert.
|
|
133
|
+
if isinstance(old_sig, dict):
|
|
134
|
+
old_content = old_sig.get('content_hash')
|
|
135
|
+
old_struct = old_sig.get('structural_hash')
|
|
136
|
+
else:
|
|
137
|
+
old_content, old_struct = old_sig[0], old_sig[1]
|
|
138
|
+
new_content, new_struct = new_sig
|
|
139
|
+
if old_content == new_content:
|
|
140
|
+
return 'NONE'
|
|
141
|
+
if old_struct == new_struct:
|
|
142
|
+
return 'COSMETIC'
|
|
143
|
+
return 'STRUCTURAL'
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Suffixes the incremental builder cares about ─────────────────────────────
|
|
147
|
+
# Keep in sync with kg_incremental.py's filter: ('.vue', '.ts', '.java').
|
|
148
|
+
TRACKED_SUFFIXES = ('.vue', '.ts', '.java')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == '__main__':
|
|
152
|
+
# Quick self-test: same code + extra comment → COSMETIC; changed code → STRUCTURAL.
|
|
153
|
+
import tempfile
|
|
154
|
+
tmp = tempfile.NamedTemporaryFile(suffix='.ts', delete=False, mode='w', encoding='utf-8')
|
|
155
|
+
tmp.write('export function add(a, b) {\n return a + b\n}\n')
|
|
156
|
+
tmp.close()
|
|
157
|
+
h1 = compute_hashes(tmp.name)
|
|
158
|
+
print('original:', h1)
|
|
159
|
+
|
|
160
|
+
with open(tmp.name, 'w', encoding='utf-8') as f:
|
|
161
|
+
f.write('// leading comment\nexport function add(a, b) {\n return a + b\n}\n')
|
|
162
|
+
h2 = compute_hashes(tmp.name)
|
|
163
|
+
print('comment-only change:', h2, '→', classify(h1, h2))
|
|
164
|
+
|
|
165
|
+
with open(tmp.name, 'w', encoding='utf-8') as f:
|
|
166
|
+
f.write('export function add(a, b, c) {\n return a + b + c\n}\n')
|
|
167
|
+
h3 = compute_hashes(tmp.name)
|
|
168
|
+
print('structural change:', h3, '→', classify(h1, h3))
|
|
169
|
+
os.unlink(tmp.name)
|
|
@@ -65,6 +65,117 @@ def _find_calls_in_function_ast(func_node, source_bytes):
|
|
|
65
65
|
return calls
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
# ── P1: calls / imports 边抽取 ────────────────────────────────────────────
|
|
69
|
+
# 把 tree-sitter 已能识别的 call_expression 和 import 语句导出成图边,
|
|
70
|
+
# 让知识图谱从"按钮→接口→控制器"单条业务链升级为带函数级调用的代码图。
|
|
71
|
+
# 返回结构供 kg_build / kg_incremental 写成 entities(FUNCTION) + edges(calls/imports)。
|
|
72
|
+
|
|
73
|
+
# 函数定义节点类型 (TS/JS): 命名函数 / const fn = () => {} / 类方法
|
|
74
|
+
_FN_NODE_TYPES = (
|
|
75
|
+
'function_declaration', # function foo() {}
|
|
76
|
+
'method_definition', # class { foo() {} }
|
|
77
|
+
# lexical_declaration 内的 arrow_function 由下面单独处理 (export const fn = () => {})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fn_name_from_node(node, source_bytes):
|
|
82
|
+
"""Extract a function's name from a definition node, or None."""
|
|
83
|
+
if node.type == 'function_declaration':
|
|
84
|
+
name_node = node.child_by_field_name('name')
|
|
85
|
+
return _node_text(name_node, source_bytes) if name_node else None
|
|
86
|
+
if node.type == 'method_definition':
|
|
87
|
+
name_node = node.child_by_field_name('name')
|
|
88
|
+
return _node_text(name_node, source_bytes) if name_node else None
|
|
89
|
+
if node.type == 'lexical_declaration':
|
|
90
|
+
# export const fnName = (...) => { ... } / const fnName = function() {}
|
|
91
|
+
for child in node.children:
|
|
92
|
+
if child.type == 'variable_declarator':
|
|
93
|
+
name_node = child.child_by_field_name('name')
|
|
94
|
+
value = child.child_by_field_name('value')
|
|
95
|
+
if name_node and value and value.type in ('arrow_function', 'function_expression'):
|
|
96
|
+
return _node_text(name_node, source_bytes)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ts_extract_calls(content):
|
|
101
|
+
"""Extract function-level call edges from TS/JS/Vue-script source.
|
|
102
|
+
|
|
103
|
+
Walks every function definition in the AST, then reuses
|
|
104
|
+
_find_calls_in_function_ast to collect each function's callees.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
[(from_fn, callee), ...] — caller function name and callee text.
|
|
108
|
+
Callee text may be qualified (e.g. 'obj.method', 'useStore()');
|
|
109
|
+
callers choose how to resolve. Tree-sitter unavailable → [] .
|
|
110
|
+
"""
|
|
111
|
+
if not _TS_AVAILABLE or not content:
|
|
112
|
+
return []
|
|
113
|
+
try:
|
|
114
|
+
source = content.encode('utf-8')
|
|
115
|
+
tree = _PARSER.parse(source)
|
|
116
|
+
except Exception:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
edges = []
|
|
120
|
+
# Collect every function definition (named + arrow + method).
|
|
121
|
+
fns = []
|
|
122
|
+
|
|
123
|
+
def collect_fns(node):
|
|
124
|
+
if node.type in _FN_NODE_TYPES:
|
|
125
|
+
fns.append(node)
|
|
126
|
+
# lexical_declaration may hold `const fn = () => {}` — handled in _fn_name_from_node
|
|
127
|
+
if node.type == 'lexical_declaration':
|
|
128
|
+
fns.append(node)
|
|
129
|
+
for child in node.children:
|
|
130
|
+
collect_fns(child)
|
|
131
|
+
|
|
132
|
+
collect_fns(tree.root_node)
|
|
133
|
+
|
|
134
|
+
for fn_node in fns:
|
|
135
|
+
caller = _fn_name_from_node(fn_node, source)
|
|
136
|
+
if not caller:
|
|
137
|
+
continue
|
|
138
|
+
for callee, _full in _find_calls_in_function_ast(fn_node, source):
|
|
139
|
+
# Skip trivial self-references and empty callees.
|
|
140
|
+
callee = callee.strip()
|
|
141
|
+
if callee and callee != caller:
|
|
142
|
+
edges.append((caller, callee))
|
|
143
|
+
return edges
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ts_extract_imports(content):
|
|
147
|
+
"""Extract import edges from TS/JS source (local module imports only).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
[(imported_name, source_path), ...] for local imports (relative paths
|
|
151
|
+
or @/ #/ alias). Third-party bare specifiers (e.g. 'vue', 'axios') are
|
|
152
|
+
filtered out — they aren't part of the project graph.
|
|
153
|
+
"""
|
|
154
|
+
if not content:
|
|
155
|
+
return []
|
|
156
|
+
out = []
|
|
157
|
+
# Generic local import: import { a, b } from './x' | import x from '@/y' | import * as z from '../z'
|
|
158
|
+
for m in re.finditer(
|
|
159
|
+
r"import\s+(?:\{([^}]*)\}|\*\s+as\s+(\w+)|(\w+))\s*(?:,\s*\{([^}]*)\})?\s+from\s+['\"]([^'\"]+)['\"]",
|
|
160
|
+
content,
|
|
161
|
+
):
|
|
162
|
+
names_group = m.group(1) or m.group(4) # named imports {a, b}
|
|
163
|
+
default_name = m.group(2) or m.group(3) # * as z or default x
|
|
164
|
+
src = (m.group(5) or '').strip()
|
|
165
|
+
# Only local imports (relative or alias); skip bare packages like 'vue'.
|
|
166
|
+
if not src or not (src.startswith('.') or src.startswith('@/') or src.startswith('#/')):
|
|
167
|
+
continue
|
|
168
|
+
names = []
|
|
169
|
+
if names_group:
|
|
170
|
+
names.extend(n.strip().split(' as ')[-1].strip() for n in names_group.split(',') if n.strip())
|
|
171
|
+
if default_name:
|
|
172
|
+
names.append(default_name.strip())
|
|
173
|
+
for name in names:
|
|
174
|
+
if name:
|
|
175
|
+
out.append((name, src))
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
68
179
|
def ts_build_api_fn_table(api_dir):
|
|
69
180
|
"""Tree-sitter version: build {fnName: {url, verb, file}} from api/*.ts.
|
|
70
181
|
|
|
@@ -29,6 +29,7 @@ Schema:
|
|
|
29
29
|
import json
|
|
30
30
|
import os
|
|
31
31
|
import sys
|
|
32
|
+
import time
|
|
32
33
|
|
|
33
34
|
import duckdb
|
|
34
35
|
|
|
@@ -102,6 +103,19 @@ def init_schema(con):
|
|
|
102
103
|
value TEXT
|
|
103
104
|
)
|
|
104
105
|
""")
|
|
106
|
+
# ── 文件签名表 (P0: fingerprint 三级增量) ──
|
|
107
|
+
# 记录每个被索引文件的 content_hash (全文) 和 structural_hash (去注释去空白)。
|
|
108
|
+
# 增量构建前比对: NONE=完全没变; COSMETIC=只改注释/空白(跳过重算);
|
|
109
|
+
# STRUCTURAL=真改结构(正常重算)。图谱只记拓扑不记实现 → COSMETIC 跳过 100% 安全。
|
|
110
|
+
con.execute("""
|
|
111
|
+
CREATE TABLE IF NOT EXISTS file_signatures (
|
|
112
|
+
file_path TEXT PRIMARY KEY,
|
|
113
|
+
content_hash TEXT,
|
|
114
|
+
structural_hash TEXT,
|
|
115
|
+
level TEXT,
|
|
116
|
+
updated_at TEXT
|
|
117
|
+
)
|
|
118
|
+
""")
|
|
105
119
|
# ── 搜索层表 ──
|
|
106
120
|
con.execute("CREATE TABLE IF NOT EXISTS keywords(keyword TEXT, file_path TEXT)")
|
|
107
121
|
con.execute("CREATE TABLE IF NOT EXISTS endpoints(endpoint TEXT PRIMARY KEY, controller_file TEXT)")
|
|
@@ -182,6 +196,8 @@ def init_schema(con):
|
|
|
182
196
|
con.execute("CREATE INDEX IF NOT EXISTS idx_alias ON aliases(alias)")
|
|
183
197
|
con.execute("CREATE INDEX IF NOT EXISTS idx_edge_from ON edges(from_id)")
|
|
184
198
|
con.execute("CREATE INDEX IF NOT EXISTS idx_edge_to ON edges(to_id)")
|
|
199
|
+
# P1: 复合索引加速按类型的边查询 (calls/imports 边量大, multi_hop 按类型过滤)
|
|
200
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_edge_from_type ON edges(from_id, edge_type)")
|
|
185
201
|
con.execute("CREATE INDEX IF NOT EXISTS idx_kw ON keywords(keyword)")
|
|
186
202
|
con.execute("CREATE INDEX IF NOT EXISTS idx_page_type ON pages(page_type)")
|
|
187
203
|
con.execute("CREATE INDEX IF NOT EXISTS idx_field ON fields(field)")
|
|
@@ -192,6 +208,7 @@ def init_schema(con):
|
|
|
192
208
|
con.execute("CREATE INDEX IF NOT EXISTS idx_learn_dev ON learning_events(dev)")
|
|
193
209
|
con.execute("CREATE INDEX IF NOT EXISTS idx_learn_pat_cat ON learning_patterns(category)")
|
|
194
210
|
con.execute("CREATE INDEX IF NOT EXISTS idx_wf_mod ON workflows(module)")
|
|
211
|
+
con.execute("CREATE INDEX IF NOT EXISTS idx_fsig ON file_signatures(file_path)")
|
|
195
212
|
|
|
196
213
|
|
|
197
214
|
def _sql_escape(val):
|
|
@@ -244,6 +261,32 @@ def delete_edges_from(con, from_id, edge_type=None):
|
|
|
244
261
|
con.execute("DELETE FROM edges WHERE from_id = ?", [from_id])
|
|
245
262
|
|
|
246
263
|
|
|
264
|
+
def upsert_file_signature(con, file_path, content_hash, structural_hash, level):
|
|
265
|
+
"""Insert or replace a file's signature (P0: fingerprint incremental).
|
|
266
|
+
|
|
267
|
+
Called after classifying a changed file; stored so the next incremental
|
|
268
|
+
run can compare and skip COSMETIC-only changes.
|
|
269
|
+
"""
|
|
270
|
+
con.execute(
|
|
271
|
+
"INSERT OR REPLACE INTO file_signatures VALUES (?, ?, ?, ?, ?)",
|
|
272
|
+
[file_path, content_hash, structural_hash, level, str(int(time.time()))]
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def get_file_signature(con, file_path):
|
|
277
|
+
"""Read a file's stored signature, or None if never seen."""
|
|
278
|
+
row = con.execute(
|
|
279
|
+
"SELECT content_hash, structural_hash FROM file_signatures WHERE file_path = ?",
|
|
280
|
+
[file_path]
|
|
281
|
+
).fetchone()
|
|
282
|
+
return {'content_hash': row[0], 'structural_hash': row[1]} if row else None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def clear_file_signatures(con):
|
|
286
|
+
"""Wipe all signatures (full rebuild resets the baseline)."""
|
|
287
|
+
con.execute("DELETE FROM file_signatures")
|
|
288
|
+
|
|
289
|
+
|
|
247
290
|
def resolve_entity(con, alias):
|
|
248
291
|
"""Resolve any alias to its canonical entity ID."""
|
|
249
292
|
row = con.execute(
|
|
@@ -29,7 +29,7 @@ for _ in range(10):
|
|
|
29
29
|
break
|
|
30
30
|
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
31
31
|
from bootstrap import setup; setup()
|
|
32
|
-
from foundation.core.paths import get_repo_root
|
|
32
|
+
from foundation.core.paths import get_repo_root
|
|
33
33
|
|
|
34
34
|
import os
|
|
35
35
|
import sys
|
|
@@ -164,10 +164,43 @@ def cmd_promote(req_id, dev=None):
|
|
|
164
164
|
return 0
|
|
165
165
|
|
|
166
166
|
|
|
167
|
-
def
|
|
168
|
-
"""
|
|
167
|
+
def _parse_prd_meta(prd_path):
|
|
168
|
+
"""从 PRD 提取发布所需元信息: 标题(首个#行) + spec(正文) + 可选 product/plan/build。
|
|
169
|
+
|
|
170
|
+
PRD 可在首行用 frontmatter 风格标注禅道目标:
|
|
171
|
+
<!-- zentao: product=1 plan=12 build=8 -->
|
|
172
|
+
没标则返回 None, 由发布方补。
|
|
173
|
+
"""
|
|
174
|
+
text = prd_path.read_text(encoding="utf-8")
|
|
175
|
+
meta = {"product_id": None, "plan_id": None, "build_id": None,
|
|
176
|
+
"title": "", "spec": ""}
|
|
177
|
+
for line in text.splitlines():
|
|
178
|
+
s = line.strip()
|
|
179
|
+
if s.startswith("<!--") and "zentao" in s:
|
|
180
|
+
for kv in s.replace("<!--", "").replace("-->", "").split():
|
|
181
|
+
if "=" in kv:
|
|
182
|
+
k, v = kv.split("=", 1)
|
|
183
|
+
kk = {"product": "product_id", "plan": "plan_id", "build": "build_id"}.get(k)
|
|
184
|
+
if kk and v.isdigit():
|
|
185
|
+
meta[kk] = int(v)
|
|
186
|
+
# 标题: 第一个非空 # 行; spec: 全文(截断到 8000 防 API 截断)
|
|
187
|
+
for line in text.splitlines():
|
|
188
|
+
if line.startswith("# ") and line[2:].strip():
|
|
189
|
+
meta["title"] = line[2:].strip()[:80]
|
|
190
|
+
break
|
|
191
|
+
meta["spec"] = text[:8000]
|
|
192
|
+
return meta
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_publish(req_id, dev=None, product_id=None, plan_id=None, build_id=None, confirm=False):
|
|
196
|
+
"""发布 = promote + 发禅道(建需求+关联计划+纳入版本)。
|
|
197
|
+
|
|
198
|
+
默认 dry-run(只打印不写禅道); --confirm 才真发。
|
|
199
|
+
产品/计划/版本: 优先用参数 > PRD里<!-- zentao: -->标注 > 缺则停在第该步问PM。
|
|
200
|
+
用 ZentaoClient 直连 HTTP(不走 MCP), AI 只触发一次, 省 token。
|
|
201
|
+
"""
|
|
169
202
|
dev = dev or get_developer(BASE) or "unknown"
|
|
170
|
-
#
|
|
203
|
+
# 1. promote (草稿→产出)
|
|
171
204
|
out_found = [e for e in find_req_dirs(req_id, dev, BASE) if e["area"] == "outputs"]
|
|
172
205
|
if not out_found:
|
|
173
206
|
print("草稿 → 产出...")
|
|
@@ -179,30 +212,92 @@ def cmd_publish(req_id, dev=None):
|
|
|
179
212
|
print("✗ 发布失败: 产出区找不到 %s" % req_id)
|
|
180
213
|
return 1
|
|
181
214
|
out_path = out_found[0]["path"]
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# 禅道发布: 读 PRD, 调 zentao MCP 建 story
|
|
189
|
-
prd_file = None
|
|
190
|
-
for f in out_path.iterdir():
|
|
191
|
-
if f.is_file() and (f.name.endswith(".md") and "prd" in f.name.lower()):
|
|
215
|
+
|
|
216
|
+
# 2. 找 PRD
|
|
217
|
+
prd_file = None
|
|
218
|
+
for f in out_path.iterdir():
|
|
219
|
+
if f.is_file() and f.suffix == ".md" and not f.name.startswith("_"):
|
|
220
|
+
if "prd" in f.name.lower() or prd_file is None:
|
|
192
221
|
prd_file = f
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
222
|
+
if not prd_file:
|
|
223
|
+
print("✗ 发布失败: 产出区没有 .md (PRD)。")
|
|
224
|
+
return 1
|
|
225
|
+
meta = _parse_prd_meta(prd_file)
|
|
226
|
+
pid = product_id or meta["product_id"]
|
|
227
|
+
plan = plan_id or meta["plan_id"]
|
|
228
|
+
build = build_id or meta["build_id"]
|
|
229
|
+
|
|
230
|
+
mode = "★真发" if confirm else "DRY-RUN(只预览不写禅道, 加 --confirm 真发)"
|
|
231
|
+
print("发禅道 [%s] 需求: %s PRD: %s" % (mode, req_id, prd_file.name))
|
|
232
|
+
print("-" * 50)
|
|
233
|
+
print(" 标题: %s" % meta["title"] or "(未提取到#标题)")
|
|
234
|
+
print(" 产品: %s" % (pid if pid else "⚠️ 未指定 — 用 --product=N 或 PRD标 <!-- zentao: product=N -->"))
|
|
235
|
+
|
|
236
|
+
if not pid:
|
|
237
|
+
print("\n✗ 缺产品ID, 已停(禅道需求必须挂某产品)。")
|
|
238
|
+
print(" 补: python req.py %s 发布 --product=N [--plan=M --build=K]" % req_id)
|
|
239
|
+
return 2
|
|
240
|
+
if not confirm:
|
|
241
|
+
print("\n [预览] 将: 建 story(product=%s) → %s%s" % (
|
|
242
|
+
pid,
|
|
243
|
+
"关联计划#%s → " % plan if plan else "",
|
|
244
|
+
"纳入版本#%s" % build if build else ""))
|
|
245
|
+
print(" 真发加 --confirm")
|
|
202
246
|
return 0
|
|
247
|
+
|
|
248
|
+
# 3. 真发: 直连禅道 HTTP
|
|
249
|
+
try:
|
|
250
|
+
from foundation.integrations.zentao_client import ZentaoClient
|
|
203
251
|
except Exception as e:
|
|
204
|
-
print("✗
|
|
205
|
-
return
|
|
252
|
+
print("✗ 加载禅道客户端失败: %s" % e)
|
|
253
|
+
return 3
|
|
254
|
+
client = ZentaoClient.from_config()
|
|
255
|
+
if client is None:
|
|
256
|
+
print("✗ 禅道未配置(凭据/内网)。先 /wl-init 或检查 ~/.qoderwork/.secrets/zentao.env")
|
|
257
|
+
return 3
|
|
258
|
+
ok, msg = client.check_intranet()
|
|
259
|
+
if not ok:
|
|
260
|
+
print("✗ 禅道不可达: %s" % msg)
|
|
261
|
+
return 3
|
|
262
|
+
|
|
263
|
+
# 3a. 建 story
|
|
264
|
+
payload = {"title": meta["title"] or req_id, "spec": meta["spec"], "pri": 3, "category": "feature"}
|
|
265
|
+
r = client.api("POST", "/products/%s/stories" % pid, payload)
|
|
266
|
+
if r.status_code not in (200, 201):
|
|
267
|
+
print("✗ 建需求失败: %s %s" % (r.status_code, r.text[:120]))
|
|
268
|
+
return 3
|
|
269
|
+
story_id = r.json().get("id")
|
|
270
|
+
if not story_id:
|
|
271
|
+
print("✗ 建需求异常(无id): %s" % str(r.json())[:150])
|
|
272
|
+
return 3
|
|
273
|
+
print(" ✅ 需求已建 #%s" % story_id)
|
|
274
|
+
|
|
275
|
+
# 3b. 关联计划 (可选)
|
|
276
|
+
if plan:
|
|
277
|
+
r2 = client.api("POST", "/plans/%s/linkStory" % plan, {"stories": [story_id]})
|
|
278
|
+
if r2.status_code in (200, 201):
|
|
279
|
+
print(" ✅ 已关联计划 #%s" % plan)
|
|
280
|
+
else:
|
|
281
|
+
print(" ⚠️ 关联计划#%s 失败: %s (不影响,可手动)" % (plan, r2.status_code))
|
|
282
|
+
|
|
283
|
+
# 3c. 纳入版本 (可选)
|
|
284
|
+
if build:
|
|
285
|
+
r3 = client.api("POST", "/builds/%s/linkStory" % build, {"stories": [story_id]})
|
|
286
|
+
if r3.status_code in (200, 201):
|
|
287
|
+
print(" ✅ 已纳入版本 #%s" % build)
|
|
288
|
+
else:
|
|
289
|
+
print(" ⚠️ 纳入版本#%s 失败: %s (不影响,可手动)" % (build, r3.status_code))
|
|
290
|
+
|
|
291
|
+
# 3d. 回写 story_id 到 PRD (留痕, 防重复建)
|
|
292
|
+
try:
|
|
293
|
+
with open(prd_file, "a", encoding="utf-8") as fh:
|
|
294
|
+
fh.write("\n\n<!-- zentao: product=%s story=%s plan=%s build=%s -->\n" % (
|
|
295
|
+
pid, story_id, plan or 0, build or 0))
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
print("\n🎉 发布完成! 禅道需求 #%s" % story_id)
|
|
300
|
+
return 0
|
|
206
301
|
|
|
207
302
|
|
|
208
303
|
def main():
|
|
@@ -211,10 +306,21 @@ def main():
|
|
|
211
306
|
return cmd_list()
|
|
212
307
|
req_id = args[0]
|
|
213
308
|
action = args[1] if len(args) > 1 else "show"
|
|
214
|
-
|
|
309
|
+
# 发布支持 --product/--plan/--build/--confirm
|
|
310
|
+
if action in ("发布", "publish", "fabu"):
|
|
311
|
+
opts = {"product_id": None, "plan_id": None, "build_id": None, "confirm": False}
|
|
312
|
+
for a in args[2:]:
|
|
313
|
+
if a == "--confirm":
|
|
314
|
+
opts["confirm"] = True
|
|
315
|
+
elif a.startswith("--product="):
|
|
316
|
+
opts["product_id"] = int(a.split("=", 1)[1])
|
|
317
|
+
elif a.startswith("--plan="):
|
|
318
|
+
opts["plan_id"] = int(a.split("=", 1)[1])
|
|
319
|
+
elif a.startswith("--build="):
|
|
320
|
+
opts["build_id"] = int(a.split("=", 1)[1])
|
|
321
|
+
return cmd_publish(req_id, **opts)
|
|
322
|
+
elif action == "show":
|
|
215
323
|
return cmd_show(req_id)
|
|
216
|
-
elif action in ("发布", "publish", "fabu"):
|
|
217
|
-
return cmd_publish(req_id)
|
|
218
324
|
elif action == "promote":
|
|
219
325
|
return cmd_promote(req_id)
|
|
220
326
|
elif action == "list":
|