pi-ui-extend 1.0.42 → 1.0.45
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/README.md +41 -12
- package/dist/app/app.d.ts +10 -0
- package/dist/app/app.js +132 -8
- package/dist/app/cli/install.js +2 -2
- package/dist/app/commands/command-controller.d.ts +4 -0
- package/dist/app/commands/command-controller.js +11 -0
- package/dist/app/commands/command-git-actions.d.ts +25 -0
- package/dist/app/commands/command-git-actions.js +381 -0
- package/dist/app/commands/command-host.d.ts +3 -0
- package/dist/app/commands/command-host.js +5 -2
- package/dist/app/commands/command-model-actions.d.ts +1 -0
- package/dist/app/commands/command-model-actions.js +51 -34
- package/dist/app/commands/command-navigation-actions.d.ts +1 -0
- package/dist/app/commands/command-navigation-actions.js +16 -3
- package/dist/app/commands/command-registry.d.ts +2 -0
- package/dist/app/commands/command-registry.js +14 -0
- package/dist/app/commands/reload-context-inventory.js +12 -1
- package/dist/app/input/autocomplete-controller.js +61 -34
- package/dist/app/input/input-action-controller.d.ts +2 -0
- package/dist/app/input/input-action-controller.js +8 -1
- package/dist/app/input/input-controller.d.ts +2 -1
- package/dist/app/input/input-controller.js +7 -2
- package/dist/app/input/prompt-enhancer-controller.js +53 -35
- package/dist/app/input/voice-controller.d.ts +41 -45
- package/dist/app/input/voice-controller.js +351 -419
- package/dist/app/model/model-usage-controller.js +17 -7
- package/dist/app/model/model-usage-status.d.ts +4 -1
- package/dist/app/model/model-usage-status.js +270 -46
- package/dist/app/popup/menu-items-controller.d.ts +13 -3
- package/dist/app/popup/menu-items-controller.js +37 -21
- package/dist/app/popup/popup-action-controller.d.ts +12 -2
- package/dist/app/popup/popup-action-controller.js +77 -24
- package/dist/app/popup/popup-menu-controller.d.ts +36 -14
- package/dist/app/popup/popup-menu-controller.js +239 -69
- package/dist/app/rendering/dcp-stats.d.ts +9 -4
- package/dist/app/rendering/dcp-stats.js +40 -425
- package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
- package/dist/app/rendering/popup-menu-renderer.js +42 -37
- package/dist/app/rendering/render-controller.js +23 -2
- package/dist/app/rendering/status-line-renderer.d.ts +5 -0
- package/dist/app/rendering/status-line-renderer.js +41 -5
- package/dist/app/rendering/tab-line-renderer.js +26 -20
- package/dist/app/runtime.d.ts +12 -1
- package/dist/app/runtime.js +120 -12
- package/dist/app/screen/mouse-controller.d.ts +2 -0
- package/dist/app/screen/mouse-controller.js +17 -7
- package/dist/app/screen/status-controller.d.ts +4 -0
- package/dist/app/screen/status-controller.js +5 -0
- package/dist/app/session/lazy-session-manager.js +34 -0
- package/dist/app/session/session-event-controller.d.ts +1 -0
- package/dist/app/session/session-event-controller.js +10 -1
- package/dist/app/session/session-history.d.ts +1 -0
- package/dist/app/session/session-history.js +12 -1
- package/dist/app/session/session-lifecycle-controller.d.ts +6 -1
- package/dist/app/session/session-lifecycle-controller.js +10 -1
- package/dist/app/session/tabs-controller.d.ts +25 -1
- package/dist/app/session/tabs-controller.js +442 -153
- package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
- package/dist/app/subagents/subagents-widget-controller.js +6 -0
- package/dist/app/types.d.ts +11 -1
- package/dist/app/workspace/workspace-actions-controller.js +10 -2
- package/dist/app/workspace/workspace-undo.d.ts +1 -0
- package/dist/app/workspace/workspace-undo.js +1 -0
- package/dist/bundled-extensions/question/index.js +9 -1
- package/dist/bundled-extensions/question/remote.d.ts +4 -0
- package/dist/bundled-extensions/question/remote.js +33 -0
- package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
- package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
- package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
- package/dist/bundled-extensions/telegram-connector/config.js +87 -0
- package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
- package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
- package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
- package/dist/bundled-extensions/telegram-connector/index.js +195 -0
- package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
- package/dist/bundled-extensions/terminal-bell/index.js +0 -76
- package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
- package/dist/bundled-extensions/workspace-undo/index.js +191 -0
- package/dist/config.d.ts +21 -2
- package/dist/config.js +159 -32
- package/dist/default-pix-config.js +20 -10
- package/dist/schemas/pi-tools-suite-schema.d.ts +8 -4
- package/dist/schemas/pi-tools-suite-schema.js +11 -5
- package/dist/schemas/pix-schema.d.ts +12 -2
- package/dist/schemas/pix-schema.js +37 -9
- package/dist/schemas/tasks-schema.js +1 -1
- package/docs/concurrency.md +3 -1
- package/docs/desktop-mvp.md +10 -5
- package/docs/desktop-task-manager.md +81 -28
- package/docs/release.md +30 -6
- package/external/pi-tools-suite/README.md +165 -39
- package/external/pi-tools-suite/docs/session-recovery.md +7 -0
- package/external/pi-tools-suite/docs/subagent-model-pools.md +12 -5
- package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
- package/external/pi-tools-suite/package.json +6 -1
- package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
- package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
- package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
- package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
- package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
- package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
- package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
- package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
- package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
- package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +0 -2
- package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +40 -17
- package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
- package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
- package/external/pi-tools-suite/src/async-subagents/core/routing.ts +7 -4
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +34 -24
- package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
- package/external/pi-tools-suite/src/async-subagents/lib.ts +9 -1
- package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +0 -1
- package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
- package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
- package/external/pi-tools-suite/src/config.ts +14 -0
- package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
- package/external/pi-tools-suite/src/context-gateway/config.ts +66 -1
- package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
- package/external/pi-tools-suite/src/context-gateway/enforcement.ts +123 -3
- package/external/pi-tools-suite/src/context-gateway/index.ts +137 -4
- package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +5 -5
- package/external/pi-tools-suite/src/context-gateway/telemetry.ts +2 -2
- package/external/pi-tools-suite/src/context-gateway/types.ts +8 -1
- package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
- package/external/pi-tools-suite/src/dcp/auto-compress.ts +41 -14
- package/external/pi-tools-suite/src/dcp/commands.ts +14 -156
- package/external/pi-tools-suite/src/dcp/compress-tool.ts +8 -2
- package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
- package/external/pi-tools-suite/src/dcp/config.ts +58 -4
- package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
- package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
- package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
- package/external/pi-tools-suite/src/dcp/index.ts +338 -66
- package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
- package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
- package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +92 -2
- package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
- package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
- package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
- package/external/pi-tools-suite/src/dcp/state.ts +9 -0
- package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
- package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +68 -16
- package/external/pi-tools-suite/src/lib/project.ts +36 -1
- package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
- package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
- package/external/pi-tools-suite/src/resource-registry/index.ts +282 -21
- package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
- package/external/pi-tools-suite/src/todo/index.ts +21 -0
- package/external/pi-tools-suite/src/todo/todo.ts +19 -1
- package/external/pi-tools-suite/src/tool-descriptions.ts +44 -15
- package/package.json +7 -8
- package/schemas/pi-tools-suite.json +44 -12
- package/schemas/pix.json +109 -15
- package/schemas/tasks.json +1 -2
- package/skills/simplify/SKILL.md +33 -5
- package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -196
- package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
- package/skills/playwright-cli/SKILL.md +0 -420
- package/skills/playwright-cli/references/element-attributes.md +0 -23
- package/skills/playwright-cli/references/playwright-tests.md +0 -50
- package/skills/playwright-cli/references/request-mocking.md +0 -87
- package/skills/playwright-cli/references/running-code.md +0 -241
- package/skills/playwright-cli/references/session-management.md +0 -273
- package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
- package/skills/playwright-cli/references/storage-state.md +0 -290
- package/skills/playwright-cli/references/test-generation.md +0 -142
- package/skills/playwright-cli/references/tracing.md +0 -154
- package/skills/playwright-cli/references/video-recording.md +0 -147
- package/skills/spec-wiki/SKILL.md +0 -55
- package/skills/spec-wiki/evals/evals.json +0 -132
- package/skills/spec-wiki/evals/files/bootstrap/architecture/rfc-007.md +0 -14
- package/skills/spec-wiki/evals/files/bootstrap/docs/how-to-pay.md +0 -4
- package/skills/spec-wiki/evals/files/bootstrap/docs/overview.md +0 -9
- package/skills/spec-wiki/evals/files/bootstrap/requirements/payment-behavior.md +0 -17
- package/skills/spec-wiki/evals/files/bootstrap/src/payments.ts +0 -12
- package/skills/spec-wiki/evals/files/bootstrap/tests/payments.test.ts +0 -11
- package/skills/spec-wiki/evals/files/focused/src/session.ts +0 -5
- package/skills/spec-wiki/evals/files/focused/tests/session.test.ts +0 -17
- package/skills/spec-wiki/evals/files/maintenance/low-signal/incoming/capture.txt +0 -2
- package/skills/spec-wiki/evals/files/maintenance/low-signal/src/capture.ts +0 -3
- package/skills/spec-wiki/evals/files/maintenance/move/docs/old-job-contract.md +0 -18
- package/skills/spec-wiki/evals/files/maintenance/move/src/job.ts +0 -5
- package/skills/spec-wiki/evals/files/maintenance/new-relation/docs/session-refresh.md +0 -24
- package/skills/spec-wiki/evals/files/maintenance/new-relation/incoming/refresh-worker.ts +0 -7
- package/skills/spec-wiki/evals/files/maintenance/new-relation/src/session.ts +0 -7
- package/skills/spec-wiki/evals/files/maintenance/no-impact/docs/auth-contract.md +0 -17
- package/skills/spec-wiki/evals/files/maintenance/no-impact/incoming/telemetry-format.ts +0 -3
- package/skills/spec-wiki/evals/files/maintenance/no-impact/src/auth.ts +0 -3
- package/skills/spec-wiki/evals/files/query/docs/how-to-run-jobs.md +0 -4
- package/skills/spec-wiki/evals/files/query/docs/spec-overview.md +0 -9
- package/skills/spec-wiki/evals/files/query/features/jobs/cancellation-contract.md +0 -15
- package/skills/spec-wiki/evals/files/query/features/jobs/retry-spec.md +0 -6
- package/skills/spec-wiki/evals/maintenance_regression.py +0 -311
- package/skills/spec-wiki/evals/retrieval-eval.md +0 -37
- package/skills/spec-wiki/evals/retrieval-evals.json +0 -37
- package/skills/spec-wiki/evals/trigger-evals.json +0 -94
- package/skills/spec-wiki/references/bootstrap.md +0 -100
- package/skills/spec-wiki/references/focused-spec.md +0 -117
- package/skills/spec-wiki/references/index-schema.md +0 -330
- package/skills/spec-wiki/references/maintenance.md +0 -134
- package/skills/spec-wiki/references/query.md +0 -74
- package/skills/spec-wiki/scripts/spec_wiki.py +0 -2076
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
|
@@ -1,2076 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Deterministic storage/discovery layer for the spec-wiki skill."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import fnmatch
|
|
8
|
-
import hashlib
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
import re
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
import tempfile
|
|
15
|
-
import unicodedata
|
|
16
|
-
from datetime import datetime, timezone
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
from typing import Any, Iterable
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
SCHEMA_VERSION = 2
|
|
22
|
-
SPEC_CLASSIFICATIONS = {"spec", "spec-like"}
|
|
23
|
-
CLASSIFICATIONS = SPEC_CLASSIFICATIONS | {"meta-index", "design-only", "guide", "other"}
|
|
24
|
-
SPEC_TYPES = {"as-is", "change", "mixed", "unknown"}
|
|
25
|
-
CONFIDENCES = {"high", "medium", "low", "unknown"}
|
|
26
|
-
LIFECYCLES = {"active", "proposed", "historical", "superseded", "unknown"}
|
|
27
|
-
SPEC_RELATION_KINDS = {"related", "supersedes", "superseded-by"}
|
|
28
|
-
DOC_EXTENSIONS = {".md", ".mdx", ".rst", ".adoc", ".txt"}
|
|
29
|
-
DEFAULT_IGNORED_DIRS = {
|
|
30
|
-
".git",
|
|
31
|
-
".hg",
|
|
32
|
-
".svn",
|
|
33
|
-
".spec-wiki",
|
|
34
|
-
".idea",
|
|
35
|
-
".vscode",
|
|
36
|
-
".venv",
|
|
37
|
-
"venv",
|
|
38
|
-
"node_modules",
|
|
39
|
-
"vendor",
|
|
40
|
-
"dist",
|
|
41
|
-
"build",
|
|
42
|
-
"coverage",
|
|
43
|
-
".next",
|
|
44
|
-
".nuxt",
|
|
45
|
-
".cache",
|
|
46
|
-
"__pycache__",
|
|
47
|
-
}
|
|
48
|
-
DEFAULT_CONFIG: dict[str, Any] = {
|
|
49
|
-
"includeExtensions": sorted(DOC_EXTENSIONS),
|
|
50
|
-
"include": [],
|
|
51
|
-
"exclude": [],
|
|
52
|
-
"forceSpecs": [],
|
|
53
|
-
"ignoreSpecs": [],
|
|
54
|
-
"minCandidateScore": 3,
|
|
55
|
-
"maxFileBytes": 512 * 1024,
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
SPEC_NAME_RE = re.compile(r"(?:^|[-_.])(spec|contract|requirements?|rfc|protocol|behavior)(?:[-_.]|$)", re.I)
|
|
59
|
-
SPEC_DIR_NAMES = {"spec", "specs", "contracts", "requirements", "rfcs", "rfc", "protocols"}
|
|
60
|
-
FIXTURE_DIR_NAMES = {"evals", "fixtures", "fixture", "examples", "example", "testdata", "samples", "sample"}
|
|
61
|
-
SKILL_RESOURCE_DIR_NAMES = {"skills", "agents"}
|
|
62
|
-
DESIGN_NAME_RE = re.compile(r"(?:^|[-_.])(adr|design|decision|evidence|measurement|review)(?:[-_.]|$)", re.I)
|
|
63
|
-
SPEC_HEADING_RE = re.compile(
|
|
64
|
-
r"^(?:#+\s*)?(?:"
|
|
65
|
-
r"behavior|behaviour|requirements?|contracts?|invariants?|scope|non-goals?|"
|
|
66
|
-
r"acceptance(?: criteria)?|expected behavior|verification|edge cases?|public contracts?|"
|
|
67
|
-
r"current behavior|intended behavior|semantics|compatibility"
|
|
68
|
-
r")\s*:?[\s#]*$",
|
|
69
|
-
re.I,
|
|
70
|
-
)
|
|
71
|
-
META_NAME_RE = re.compile(r"^(?:\d+[-_.])?(?:readme|index|overview|toc|catalog|contents?)(?:[-_.].*)?$", re.I)
|
|
72
|
-
META_TEXT_RE = re.compile(r"\b(?:specs? index|spec inventory|full spec inventory|table of contents|documentation index|catalog of specs)\b", re.I)
|
|
73
|
-
TYPE_AS_IS_RE = re.compile(r"\b(?:as-is|current behavior|current behaviour)\b", re.I)
|
|
74
|
-
TYPE_CHANGE_RE = re.compile(r"\b(?:change spec|intended behavior|intended behaviour|proposed behavior|target behavior)\b", re.I)
|
|
75
|
-
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
|
76
|
-
BACKTICK_RE = re.compile(r"`([^`\n]+)`")
|
|
77
|
-
PLAIN_PATH_RE = re.compile(r"(?<![\w/])(?:[A-Za-z0-9_.@+-]+/)+[A-Za-z0-9_.@+\-]+\.[A-Za-z0-9]{1,12}")
|
|
78
|
-
SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
79
|
-
RELATIVE_BASE_RE = re.compile(
|
|
80
|
-
r"(?:paths?|references?|files?)\b[^\n]{0,80}?\brelative\s+to\s+`([^`]+)`",
|
|
81
|
-
re.I,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def utc_now() -> str:
|
|
86
|
-
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def sha256_bytes(data: bytes) -> str:
|
|
90
|
-
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def file_hash(path: Path) -> str | None:
|
|
94
|
-
try:
|
|
95
|
-
digest = hashlib.sha256()
|
|
96
|
-
with path.open("rb") as handle:
|
|
97
|
-
while chunk := handle.read(1024 * 1024):
|
|
98
|
-
digest.update(chunk)
|
|
99
|
-
return "sha256:" + digest.hexdigest()
|
|
100
|
-
except (OSError, PermissionError):
|
|
101
|
-
return None
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def normalize_rel_path(root: Path, value: str | Path) -> str:
|
|
105
|
-
raw = Path(value)
|
|
106
|
-
absolute = raw if raw.is_absolute() else root / raw
|
|
107
|
-
resolved = absolute.resolve(strict=False)
|
|
108
|
-
try:
|
|
109
|
-
relative = resolved.relative_to(root.resolve())
|
|
110
|
-
except ValueError as exc:
|
|
111
|
-
raise ValueError(f"path escapes project root: {value}") from exc
|
|
112
|
-
return relative.as_posix()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def safe_project_path(root: Path, rel: str) -> Path:
|
|
116
|
-
normalized = normalize_rel_path(root, rel)
|
|
117
|
-
return root / normalized
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def strip_jsonc(text: str) -> str:
|
|
121
|
-
output: list[str] = []
|
|
122
|
-
i = 0
|
|
123
|
-
in_string = False
|
|
124
|
-
escaped = False
|
|
125
|
-
while i < len(text):
|
|
126
|
-
char = text[i]
|
|
127
|
-
nxt = text[i + 1] if i + 1 < len(text) else ""
|
|
128
|
-
if in_string:
|
|
129
|
-
output.append(char)
|
|
130
|
-
if escaped:
|
|
131
|
-
escaped = False
|
|
132
|
-
elif char == "\\":
|
|
133
|
-
escaped = True
|
|
134
|
-
elif char == '"':
|
|
135
|
-
in_string = False
|
|
136
|
-
i += 1
|
|
137
|
-
continue
|
|
138
|
-
if char == '"':
|
|
139
|
-
in_string = True
|
|
140
|
-
output.append(char)
|
|
141
|
-
i += 1
|
|
142
|
-
continue
|
|
143
|
-
if char == "/" and nxt == "/":
|
|
144
|
-
i += 2
|
|
145
|
-
while i < len(text) and text[i] not in "\r\n":
|
|
146
|
-
i += 1
|
|
147
|
-
continue
|
|
148
|
-
if char == "/" and nxt == "*":
|
|
149
|
-
i += 2
|
|
150
|
-
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
|
|
151
|
-
i += 1
|
|
152
|
-
i += 2
|
|
153
|
-
continue
|
|
154
|
-
output.append(char)
|
|
155
|
-
i += 1
|
|
156
|
-
cleaned = "".join(output)
|
|
157
|
-
output = []
|
|
158
|
-
in_string = False
|
|
159
|
-
escaped = False
|
|
160
|
-
for index, char in enumerate(cleaned):
|
|
161
|
-
if in_string:
|
|
162
|
-
output.append(char)
|
|
163
|
-
if escaped:
|
|
164
|
-
escaped = False
|
|
165
|
-
elif char == "\\":
|
|
166
|
-
escaped = True
|
|
167
|
-
elif char == '"':
|
|
168
|
-
in_string = False
|
|
169
|
-
continue
|
|
170
|
-
if char == '"':
|
|
171
|
-
in_string = True
|
|
172
|
-
output.append(char)
|
|
173
|
-
continue
|
|
174
|
-
if char == ",":
|
|
175
|
-
cursor = index + 1
|
|
176
|
-
while cursor < len(cleaned) and cleaned[cursor].isspace():
|
|
177
|
-
cursor += 1
|
|
178
|
-
if cursor < len(cleaned) and cleaned[cursor] in "}]":
|
|
179
|
-
continue
|
|
180
|
-
output.append(char)
|
|
181
|
-
return "".join(output)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def detect_root(explicit: str | None) -> Path:
|
|
185
|
-
if explicit:
|
|
186
|
-
return Path(explicit).expanduser().resolve()
|
|
187
|
-
try:
|
|
188
|
-
proc = subprocess.run(
|
|
189
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
190
|
-
check=True,
|
|
191
|
-
stdout=subprocess.PIPE,
|
|
192
|
-
stderr=subprocess.DEVNULL,
|
|
193
|
-
text=True,
|
|
194
|
-
timeout=5,
|
|
195
|
-
)
|
|
196
|
-
value = proc.stdout.strip()
|
|
197
|
-
if value:
|
|
198
|
-
return Path(value).resolve()
|
|
199
|
-
except (FileNotFoundError, subprocess.SubprocessError):
|
|
200
|
-
pass
|
|
201
|
-
return Path.cwd().resolve()
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def resolve_state_dir(root: Path, value: str | None) -> Path:
|
|
205
|
-
if not value:
|
|
206
|
-
candidate = root / ".spec-wiki"
|
|
207
|
-
if candidate.is_symlink():
|
|
208
|
-
raise ValueError("default .spec-wiki directory must not be a symlink")
|
|
209
|
-
return candidate
|
|
210
|
-
candidate = Path(value).expanduser()
|
|
211
|
-
if candidate.is_absolute():
|
|
212
|
-
return candidate.resolve()
|
|
213
|
-
resolved = (root / candidate).resolve(strict=False)
|
|
214
|
-
try:
|
|
215
|
-
resolved.relative_to(root.resolve())
|
|
216
|
-
except ValueError as exc:
|
|
217
|
-
raise ValueError(f"relative --state-dir escapes project root: {value}") from exc
|
|
218
|
-
return resolved
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def load_config(root: Path, state_dir: Path, explicit: str | None) -> dict[str, Any]:
|
|
222
|
-
config = dict(DEFAULT_CONFIG)
|
|
223
|
-
config_path = Path(explicit).expanduser() if explicit else state_dir / "config.jsonc"
|
|
224
|
-
if not config_path.is_absolute():
|
|
225
|
-
config_path = root / config_path
|
|
226
|
-
if not config_path.exists():
|
|
227
|
-
fallback = config_path.with_suffix(".json") if config_path.suffix == ".jsonc" else None
|
|
228
|
-
if not fallback or not fallback.exists():
|
|
229
|
-
return config
|
|
230
|
-
config_path = fallback
|
|
231
|
-
try:
|
|
232
|
-
parsed = json.loads(strip_jsonc(config_path.read_text(encoding="utf-8")))
|
|
233
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
234
|
-
raise ValueError(f"failed to parse config {config_path}: {exc}") from exc
|
|
235
|
-
if not isinstance(parsed, dict):
|
|
236
|
-
raise ValueError(f"config must be a JSON object: {config_path}")
|
|
237
|
-
config.update(parsed)
|
|
238
|
-
for key in ("includeExtensions", "include", "exclude", "forceSpecs", "ignoreSpecs"):
|
|
239
|
-
value = config.get(key)
|
|
240
|
-
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
241
|
-
raise ValueError(f"config.{key} must be an array of strings")
|
|
242
|
-
for key in ("minCandidateScore", "maxFileBytes"):
|
|
243
|
-
value = config.get(key)
|
|
244
|
-
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
245
|
-
raise ValueError(f"config.{key} must be a non-negative integer")
|
|
246
|
-
if config["maxFileBytes"] == 0:
|
|
247
|
-
raise ValueError("config.maxFileBytes must be greater than zero")
|
|
248
|
-
return config
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def state_file(state_dir: Path) -> Path:
|
|
252
|
-
return state_dir / "state.json"
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def index_file(state_dir: Path) -> Path:
|
|
256
|
-
return state_dir / "index.md"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def empty_state() -> dict[str, Any]:
|
|
260
|
-
return {"schemaVersion": SCHEMA_VERSION, "entries": {}}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def load_state(state_dir: Path) -> dict[str, Any]:
|
|
264
|
-
path = state_file(state_dir)
|
|
265
|
-
if not path.exists():
|
|
266
|
-
return empty_state()
|
|
267
|
-
try:
|
|
268
|
-
state = json.loads(path.read_text(encoding="utf-8"))
|
|
269
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
270
|
-
raise ValueError(f"failed to parse state {path}: {exc}") from exc
|
|
271
|
-
if not isinstance(state, dict):
|
|
272
|
-
raise ValueError("state.json must contain a JSON object")
|
|
273
|
-
return state
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def atomic_write_text(path: Path, text: str) -> None:
|
|
277
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
-
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
279
|
-
try:
|
|
280
|
-
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
281
|
-
handle.write(text)
|
|
282
|
-
handle.flush()
|
|
283
|
-
os.fsync(handle.fileno())
|
|
284
|
-
os.replace(temp_name, path)
|
|
285
|
-
finally:
|
|
286
|
-
if os.path.exists(temp_name):
|
|
287
|
-
os.unlink(temp_name)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def save_state(state_dir: Path, state: dict[str, Any]) -> None:
|
|
291
|
-
payload = json.dumps(state, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
|
|
292
|
-
atomic_write_text(state_file(state_dir), payload)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def matches_any(path: str, patterns: Iterable[str]) -> bool:
|
|
296
|
-
return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def is_under_ignored_dir(path: str, state_dir: Path, root: Path) -> bool:
|
|
300
|
-
parts = Path(path).parts
|
|
301
|
-
if any(part in DEFAULT_IGNORED_DIRS for part in parts[:-1]):
|
|
302
|
-
return True
|
|
303
|
-
try:
|
|
304
|
-
state_rel = state_dir.resolve().relative_to(root.resolve()).as_posix()
|
|
305
|
-
except ValueError:
|
|
306
|
-
return False
|
|
307
|
-
return path == state_rel or path.startswith(state_rel.rstrip("/") + "/")
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def git_project_files(root: Path) -> list[str] | None:
|
|
311
|
-
try:
|
|
312
|
-
proc = subprocess.run(
|
|
313
|
-
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
314
|
-
cwd=root,
|
|
315
|
-
stdout=subprocess.PIPE,
|
|
316
|
-
stderr=subprocess.DEVNULL,
|
|
317
|
-
check=True,
|
|
318
|
-
timeout=30,
|
|
319
|
-
)
|
|
320
|
-
except (FileNotFoundError, subprocess.SubprocessError):
|
|
321
|
-
return None
|
|
322
|
-
return sorted({item.decode("utf-8", errors="surrogateescape") for item in proc.stdout.split(b"\0") if item})
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def git_changed_records(root: Path, base: str = "HEAD") -> list[dict[str, str]]:
|
|
326
|
-
try:
|
|
327
|
-
subprocess.run(
|
|
328
|
-
["git", "rev-parse", "--is-inside-work-tree"],
|
|
329
|
-
cwd=root,
|
|
330
|
-
stdout=subprocess.DEVNULL,
|
|
331
|
-
stderr=subprocess.DEVNULL,
|
|
332
|
-
check=True,
|
|
333
|
-
timeout=5,
|
|
334
|
-
)
|
|
335
|
-
except (FileNotFoundError, subprocess.SubprocessError) as exc:
|
|
336
|
-
raise ValueError("Git working tree is required when impact paths are not supplied explicitly") from exc
|
|
337
|
-
|
|
338
|
-
records: list[dict[str, str]] = []
|
|
339
|
-
try:
|
|
340
|
-
proc = subprocess.run(
|
|
341
|
-
["git", "diff", "--name-status", "-z", "--find-renames", base, "--"],
|
|
342
|
-
cwd=root,
|
|
343
|
-
stdout=subprocess.PIPE,
|
|
344
|
-
stderr=subprocess.PIPE,
|
|
345
|
-
check=True,
|
|
346
|
-
timeout=30,
|
|
347
|
-
)
|
|
348
|
-
except subprocess.CalledProcessError as exc:
|
|
349
|
-
message = exc.stderr.decode("utf-8", errors="replace").strip()
|
|
350
|
-
raise ValueError(f"failed to inspect Git changes against {base}: {message or exc}") from exc
|
|
351
|
-
|
|
352
|
-
parts = [part.decode("utf-8", errors="surrogateescape") for part in proc.stdout.split(b"\0") if part]
|
|
353
|
-
index = 0
|
|
354
|
-
while index < len(parts):
|
|
355
|
-
status = parts[index]
|
|
356
|
-
index += 1
|
|
357
|
-
code = status[:1]
|
|
358
|
-
if code in {"R", "C"}:
|
|
359
|
-
if index + 1 >= len(parts):
|
|
360
|
-
break
|
|
361
|
-
old_path, new_path = parts[index], parts[index + 1]
|
|
362
|
-
index += 2
|
|
363
|
-
records.append({"status": code, "oldPath": old_path.replace("\\", "/"), "path": new_path.replace("\\", "/")})
|
|
364
|
-
else:
|
|
365
|
-
if index >= len(parts):
|
|
366
|
-
break
|
|
367
|
-
path = parts[index]
|
|
368
|
-
index += 1
|
|
369
|
-
records.append({"status": code or "M", "path": path.replace("\\", "/")})
|
|
370
|
-
|
|
371
|
-
try:
|
|
372
|
-
untracked = subprocess.run(
|
|
373
|
-
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
374
|
-
cwd=root,
|
|
375
|
-
stdout=subprocess.PIPE,
|
|
376
|
-
stderr=subprocess.DEVNULL,
|
|
377
|
-
check=True,
|
|
378
|
-
timeout=30,
|
|
379
|
-
)
|
|
380
|
-
tracked_paths = {
|
|
381
|
-
item.get("path") for item in records if isinstance(item.get("path"), str)
|
|
382
|
-
}
|
|
383
|
-
for raw in untracked.stdout.split(b"\0"):
|
|
384
|
-
if not raw:
|
|
385
|
-
continue
|
|
386
|
-
path = raw.decode("utf-8", errors="surrogateescape").replace("\\", "/")
|
|
387
|
-
if path not in tracked_paths:
|
|
388
|
-
records.append({"status": "?", "path": path})
|
|
389
|
-
except (FileNotFoundError, subprocess.SubprocessError):
|
|
390
|
-
pass
|
|
391
|
-
|
|
392
|
-
return sorted(records, key=lambda item: (str(item.get("path", "")), str(item.get("oldPath", "")), str(item.get("status", ""))))
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def changed_record_paths(records: Iterable[dict[str, str]]) -> list[str]:
|
|
396
|
-
paths: set[str] = set()
|
|
397
|
-
for record in records:
|
|
398
|
-
for key in ("oldPath", "path"):
|
|
399
|
-
value = record.get(key)
|
|
400
|
-
if isinstance(value, str) and value:
|
|
401
|
-
paths.add(value.replace("\\", "/"))
|
|
402
|
-
return sorted(paths)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def filter_internal_change_records(
|
|
406
|
-
records: Iterable[dict[str, str]],
|
|
407
|
-
state_dir: Path,
|
|
408
|
-
root: Path,
|
|
409
|
-
) -> list[dict[str, str]]:
|
|
410
|
-
result: list[dict[str, str]] = []
|
|
411
|
-
for record in records:
|
|
412
|
-
values = [record.get(key) for key in ("oldPath", "path") if isinstance(record.get(key), str)]
|
|
413
|
-
if values and all(is_under_ignored_dir(value, state_dir, root) for value in values):
|
|
414
|
-
continue
|
|
415
|
-
result.append(record)
|
|
416
|
-
return result
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def walked_project_files(root: Path) -> list[str]:
|
|
420
|
-
files: list[str] = []
|
|
421
|
-
for current, dirs, names in os.walk(root):
|
|
422
|
-
dirs[:] = sorted(d for d in dirs if d not in DEFAULT_IGNORED_DIRS)
|
|
423
|
-
current_path = Path(current)
|
|
424
|
-
for name in sorted(names):
|
|
425
|
-
try:
|
|
426
|
-
files.append((current_path / name).relative_to(root).as_posix())
|
|
427
|
-
except ValueError:
|
|
428
|
-
continue
|
|
429
|
-
return files
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
def project_files(root: Path) -> list[str]:
|
|
433
|
-
git_files = git_project_files(root)
|
|
434
|
-
return git_files if git_files is not None else walked_project_files(root)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
def candidate_file_paths(root: Path, state_dir: Path, config: dict[str, Any]) -> list[str]:
|
|
438
|
-
extensions = {str(item).lower() if str(item).startswith(".") else f".{str(item).lower()}" for item in config.get("includeExtensions", [])}
|
|
439
|
-
includes = [str(item) for item in config.get("include", [])]
|
|
440
|
-
excludes = [str(item) for item in config.get("exclude", [])]
|
|
441
|
-
forced = [str(item) for item in config.get("forceSpecs", [])]
|
|
442
|
-
ignored = [str(item) for item in config.get("ignoreSpecs", [])]
|
|
443
|
-
result: list[str] = []
|
|
444
|
-
for rel in project_files(root):
|
|
445
|
-
rel = rel.replace("\\", "/")
|
|
446
|
-
if is_under_ignored_dir(rel, state_dir, root):
|
|
447
|
-
continue
|
|
448
|
-
if matches_any(rel, ignored) or matches_any(rel, excludes):
|
|
449
|
-
continue
|
|
450
|
-
is_forced = matches_any(rel, forced)
|
|
451
|
-
if not is_forced:
|
|
452
|
-
if extensions and Path(rel).suffix.lower() not in extensions:
|
|
453
|
-
continue
|
|
454
|
-
if includes and not matches_any(rel, includes):
|
|
455
|
-
continue
|
|
456
|
-
result.append(rel)
|
|
457
|
-
return result
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def read_candidate(path: Path, max_bytes: int) -> tuple[str, bytes] | None:
|
|
461
|
-
try:
|
|
462
|
-
size = path.stat().st_size
|
|
463
|
-
if size > max_bytes:
|
|
464
|
-
return None
|
|
465
|
-
data = path.read_bytes()
|
|
466
|
-
except OSError:
|
|
467
|
-
return None
|
|
468
|
-
if b"\x00" in data[:8192]:
|
|
469
|
-
return None
|
|
470
|
-
return data.decode("utf-8", errors="replace"), data
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def document_title(text: str, rel: str) -> str:
|
|
474
|
-
lines = text.splitlines()[:80]
|
|
475
|
-
for index, line in enumerate(lines):
|
|
476
|
-
match = re.match(r"^\s*(?:#{1,6}|={1,6})\s+(.+?)\s*#*\s*$", line)
|
|
477
|
-
if match:
|
|
478
|
-
return match.group(1).strip()
|
|
479
|
-
if index + 1 < len(lines) and line.strip() and re.fullmatch(r"\s*[=~^-]{3,}\s*", lines[index + 1]):
|
|
480
|
-
return line.strip()
|
|
481
|
-
return Path(rel).stem.replace("-", " ").replace("_", " ").strip()
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def discovery_signals(rel: str, text: str, forced: bool = False) -> tuple[int, str, list[str]]:
|
|
485
|
-
score = 0
|
|
486
|
-
signals: list[str] = []
|
|
487
|
-
path = Path(rel)
|
|
488
|
-
stem = path.stem
|
|
489
|
-
lower_parts = {part.lower() for part in path.parts[:-1]}
|
|
490
|
-
if forced:
|
|
491
|
-
score += 100
|
|
492
|
-
signals.append("forced-by-config")
|
|
493
|
-
if SPEC_NAME_RE.search(stem):
|
|
494
|
-
score += 4
|
|
495
|
-
signals.append("spec-like-filename")
|
|
496
|
-
if lower_parts & SPEC_DIR_NAMES:
|
|
497
|
-
score += 3
|
|
498
|
-
signals.append("spec-like-directory")
|
|
499
|
-
noisy_resource_path = bool(lower_parts & (FIXTURE_DIR_NAMES | SKILL_RESOURCE_DIR_NAMES)) and not forced
|
|
500
|
-
if lower_parts & FIXTURE_DIR_NAMES and not forced:
|
|
501
|
-
score -= 12
|
|
502
|
-
signals.append("fixture-like-path")
|
|
503
|
-
if lower_parts & SKILL_RESOURCE_DIR_NAMES and not forced:
|
|
504
|
-
score -= 12
|
|
505
|
-
signals.append("skill-resource-path")
|
|
506
|
-
if DESIGN_NAME_RE.search(stem):
|
|
507
|
-
score += 3
|
|
508
|
-
signals.append("design-like-filename")
|
|
509
|
-
|
|
510
|
-
headings = []
|
|
511
|
-
for line in text.splitlines()[:500]:
|
|
512
|
-
stripped = re.sub(r"^\s*(?:#{1,6}|={1,6})\s+", "", line).strip().rstrip("#").strip()
|
|
513
|
-
if SPEC_HEADING_RE.match(stripped):
|
|
514
|
-
headings.append(stripped.lower())
|
|
515
|
-
unique_headings = sorted(set(headings))
|
|
516
|
-
if unique_headings:
|
|
517
|
-
score += min(6, len(unique_headings) * 2)
|
|
518
|
-
signals.append("contract-headings:" + ",".join(unique_headings[:4]))
|
|
519
|
-
if TYPE_AS_IS_RE.search(text[:20000]):
|
|
520
|
-
score += 1
|
|
521
|
-
signals.append("as-is-language")
|
|
522
|
-
if TYPE_CHANGE_RE.search(text[:20000]):
|
|
523
|
-
score += 1
|
|
524
|
-
signals.append("change-language")
|
|
525
|
-
|
|
526
|
-
path_refs = set(PLAIN_PATH_RE.findall(text[:100000]))
|
|
527
|
-
if path_refs:
|
|
528
|
-
score += 1 if len(path_refs) < 3 else 2
|
|
529
|
-
signals.append(f"path-references:{min(len(path_refs), 99)}")
|
|
530
|
-
|
|
531
|
-
meta_score = 0
|
|
532
|
-
if META_NAME_RE.match(stem):
|
|
533
|
-
meta_score += 3
|
|
534
|
-
signals.append("meta-like-filename")
|
|
535
|
-
if META_TEXT_RE.search(text[:30000]):
|
|
536
|
-
meta_score += 4
|
|
537
|
-
signals.append("meta-index-language")
|
|
538
|
-
doc_links = [target for target in MARKDOWN_LINK_RE.findall(text[:100000]) if Path(target.split("#", 1)[0]).suffix.lower() in DOC_EXTENSIONS]
|
|
539
|
-
if len(doc_links) >= 4:
|
|
540
|
-
meta_score += 2
|
|
541
|
-
signals.append(f"many-doc-links:{min(len(doc_links), 99)}")
|
|
542
|
-
title = document_title(text, rel)
|
|
543
|
-
if re.search(r"\b(?:specs? index|spec inventory|documentation index)\b", title, re.I):
|
|
544
|
-
meta_score += 4
|
|
545
|
-
signals.append("meta-like-title")
|
|
546
|
-
|
|
547
|
-
if noisy_resource_path:
|
|
548
|
-
role_hint = "weak-candidate"
|
|
549
|
-
elif meta_score >= 5:
|
|
550
|
-
role_hint = "meta-index"
|
|
551
|
-
elif score >= 4:
|
|
552
|
-
role_hint = "spec-candidate"
|
|
553
|
-
else:
|
|
554
|
-
role_hint = "weak-candidate"
|
|
555
|
-
return score, role_hint, signals
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
def entry_baseline_hash(entry: dict[str, Any]) -> str | None:
|
|
559
|
-
indexed = entry.get("indexed")
|
|
560
|
-
return indexed.get("sourceHash") if isinstance(indexed, dict) else None
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
def discover_candidates(
|
|
564
|
-
root: Path,
|
|
565
|
-
state_dir: Path,
|
|
566
|
-
config: dict[str, Any],
|
|
567
|
-
state: dict[str, Any],
|
|
568
|
-
include_all: bool = False,
|
|
569
|
-
all_unclassified: bool = False,
|
|
570
|
-
) -> list[dict[str, Any]]:
|
|
571
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
572
|
-
max_bytes = int(config.get("maxFileBytes", DEFAULT_CONFIG["maxFileBytes"]))
|
|
573
|
-
min_score = int(config.get("minCandidateScore", DEFAULT_CONFIG["minCandidateScore"]))
|
|
574
|
-
forced_patterns = [str(item) for item in config.get("forceSpecs", [])]
|
|
575
|
-
candidates: list[dict[str, Any]] = []
|
|
576
|
-
for rel in candidate_file_paths(root, state_dir, config):
|
|
577
|
-
candidate_path = root / rel
|
|
578
|
-
if candidate_path.is_symlink():
|
|
579
|
-
continue
|
|
580
|
-
loaded = read_candidate(candidate_path, max_bytes)
|
|
581
|
-
if not loaded:
|
|
582
|
-
continue
|
|
583
|
-
text, data = loaded
|
|
584
|
-
forced = matches_any(rel, forced_patterns)
|
|
585
|
-
score, role_hint, signals = discovery_signals(rel, text, forced)
|
|
586
|
-
known = entries.get(rel) if isinstance(entries.get(rel), dict) else None
|
|
587
|
-
current_hash = sha256_bytes(data)
|
|
588
|
-
changed = bool(known and entry_baseline_hash(known) != current_hash)
|
|
589
|
-
if all_unclassified:
|
|
590
|
-
if known:
|
|
591
|
-
continue
|
|
592
|
-
if not forced and any(signal in {"fixture-like-path", "skill-resource-path"} for signal in signals):
|
|
593
|
-
continue
|
|
594
|
-
elif not include_all:
|
|
595
|
-
if known and not changed:
|
|
596
|
-
continue
|
|
597
|
-
if not forced and score < min_score and role_hint != "meta-index":
|
|
598
|
-
continue
|
|
599
|
-
candidates.append(
|
|
600
|
-
{
|
|
601
|
-
"path": rel,
|
|
602
|
-
"title": document_title(text, rel),
|
|
603
|
-
"score": score,
|
|
604
|
-
"roleHint": role_hint,
|
|
605
|
-
"signals": signals,
|
|
606
|
-
"currentHash": current_hash,
|
|
607
|
-
"knownClassification": known.get("classification") if known else None,
|
|
608
|
-
"changedSinceClassification": changed if known else None,
|
|
609
|
-
}
|
|
610
|
-
)
|
|
611
|
-
return sorted(
|
|
612
|
-
candidates,
|
|
613
|
-
key=lambda item: (
|
|
614
|
-
0 if item["knownClassification"] is None else 1,
|
|
615
|
-
0 if item["roleHint"] == "spec-candidate" else 1 if item["roleHint"] == "meta-index" else 2,
|
|
616
|
-
-int(item["score"]),
|
|
617
|
-
item["path"],
|
|
618
|
-
),
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def markdown_destination(token: str) -> str:
|
|
623
|
-
token = token.strip()
|
|
624
|
-
if token.startswith("<"):
|
|
625
|
-
end = token.find(">", 1)
|
|
626
|
-
return token[1:end] if end >= 0 else token
|
|
627
|
-
chars: list[str] = []
|
|
628
|
-
escaped = False
|
|
629
|
-
for char in token:
|
|
630
|
-
if escaped:
|
|
631
|
-
chars.append(char)
|
|
632
|
-
escaped = False
|
|
633
|
-
continue
|
|
634
|
-
if char == "\\":
|
|
635
|
-
escaped = True
|
|
636
|
-
continue
|
|
637
|
-
if char.isspace():
|
|
638
|
-
break
|
|
639
|
-
chars.append(char)
|
|
640
|
-
if escaped:
|
|
641
|
-
chars.append("\\")
|
|
642
|
-
return "".join(chars)
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
def clean_reference_token(token: str) -> str | None:
|
|
646
|
-
token = markdown_destination(token).strip().strip("<>\"'")
|
|
647
|
-
token = token.split("#", 1)[0].split("?", 1)[0].strip()
|
|
648
|
-
if not token or token.startswith(("http://", "https://", "mailto:", "data:", "#", "~")):
|
|
649
|
-
return None
|
|
650
|
-
if " " in token and not token.startswith("./") and not token.startswith("../"):
|
|
651
|
-
return None
|
|
652
|
-
return token
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
def strong_unresolved_path_hint(token: str) -> bool:
|
|
656
|
-
if not token or token.startswith(("/", "~", "$", "...")):
|
|
657
|
-
return False
|
|
658
|
-
if "://" in token or "::" in token or any(char in token for char in "*?<>|"):
|
|
659
|
-
return False
|
|
660
|
-
if token.endswith("/"):
|
|
661
|
-
return False
|
|
662
|
-
suffix = Path(token).suffix.lower()
|
|
663
|
-
if not suffix or len(suffix) > 12:
|
|
664
|
-
return False
|
|
665
|
-
first = token.lstrip("./").split("/", 1)[0].lower()
|
|
666
|
-
return first in {
|
|
667
|
-
"src", "test", "tests", "docs", "doc", "spec", "specs", "external",
|
|
668
|
-
"desktop", "acp", "lib", "packages", "package", ".pi", "schemas",
|
|
669
|
-
} or token.startswith(("./", "../"))
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
def reference_roots(root: Path, source_rel: str) -> list[Path]:
|
|
673
|
-
root_resolved = root.resolve()
|
|
674
|
-
source_parent = (root / source_rel).parent.resolve(strict=False)
|
|
675
|
-
result: list[Path] = [source_parent]
|
|
676
|
-
current = source_parent
|
|
677
|
-
markers = ("package.json", "pyproject.toml", "Cargo.toml", "go.mod", "Package.swift")
|
|
678
|
-
while True:
|
|
679
|
-
if current != source_parent and any((current / marker).is_file() for marker in markers):
|
|
680
|
-
result.append(current)
|
|
681
|
-
if current == root_resolved or current.parent == current:
|
|
682
|
-
break
|
|
683
|
-
current = current.parent
|
|
684
|
-
result.append(root_resolved)
|
|
685
|
-
deduped: list[Path] = []
|
|
686
|
-
seen: set[str] = set()
|
|
687
|
-
for candidate in result:
|
|
688
|
-
key = str(candidate)
|
|
689
|
-
if key not in seen:
|
|
690
|
-
seen.add(key)
|
|
691
|
-
deduped.append(candidate)
|
|
692
|
-
return deduped
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def expand_reference_token(token: str) -> list[str]:
|
|
696
|
-
cleaned = clean_reference_token(token)
|
|
697
|
-
if not cleaned:
|
|
698
|
-
return []
|
|
699
|
-
match = re.search(r"\{([^{}]+)\}", cleaned)
|
|
700
|
-
if not match:
|
|
701
|
-
return [cleaned]
|
|
702
|
-
choices = [part.strip() for part in match.group(1).split(",") if part.strip()]
|
|
703
|
-
if not choices:
|
|
704
|
-
return [cleaned]
|
|
705
|
-
return [cleaned[: match.start()] + choice + cleaned[match.end() :] for choice in choices]
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
def explicit_reference_bases(root: Path, text: str) -> list[Path]:
|
|
709
|
-
bases: list[Path] = []
|
|
710
|
-
for raw in RELATIVE_BASE_RE.findall(text[:20000]):
|
|
711
|
-
cleaned = clean_reference_token(raw)
|
|
712
|
-
if not cleaned:
|
|
713
|
-
continue
|
|
714
|
-
candidate = root / cleaned
|
|
715
|
-
try:
|
|
716
|
-
resolved = candidate.resolve(strict=True)
|
|
717
|
-
resolved.relative_to(root.resolve())
|
|
718
|
-
except (OSError, ValueError):
|
|
719
|
-
continue
|
|
720
|
-
if resolved.is_dir():
|
|
721
|
-
bases.append(resolved)
|
|
722
|
-
return bases
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
def resolve_reference(root: Path, source_rel: str, token: str, extra_bases: Iterable[Path] = ()) -> str | None:
|
|
726
|
-
cleaned = clean_reference_token(token)
|
|
727
|
-
if not cleaned or cleaned.startswith("..."):
|
|
728
|
-
return None
|
|
729
|
-
raw = Path(cleaned)
|
|
730
|
-
candidates = [raw] if raw.is_absolute() else [base / raw for base in [*extra_bases, *reference_roots(root, source_rel)]]
|
|
731
|
-
for candidate in candidates:
|
|
732
|
-
try:
|
|
733
|
-
resolved = candidate.resolve(strict=True)
|
|
734
|
-
rel = resolved.relative_to(root.resolve()).as_posix()
|
|
735
|
-
except (OSError, ValueError):
|
|
736
|
-
continue
|
|
737
|
-
if resolved.is_file():
|
|
738
|
-
return rel
|
|
739
|
-
return None
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
def explicit_relations(root: Path, source_rel: str, text: str) -> dict[str, Any]:
|
|
743
|
-
tokens: list[str] = []
|
|
744
|
-
tokens.extend(MARKDOWN_LINK_RE.findall(text))
|
|
745
|
-
tokens.extend(BACKTICK_RE.findall(text))
|
|
746
|
-
tokens.extend(PLAIN_PATH_RE.findall(text))
|
|
747
|
-
resolved: list[str] = []
|
|
748
|
-
unresolved: list[str] = []
|
|
749
|
-
extra_bases = explicit_reference_bases(root, text)
|
|
750
|
-
for token in tokens:
|
|
751
|
-
expanded = expand_reference_token(token)
|
|
752
|
-
if not expanded:
|
|
753
|
-
continue
|
|
754
|
-
token_resolved = False
|
|
755
|
-
for candidate_token in expanded:
|
|
756
|
-
path = resolve_reference(root, source_rel, candidate_token, extra_bases)
|
|
757
|
-
if path and path != source_rel:
|
|
758
|
-
resolved.append(path)
|
|
759
|
-
token_resolved = True
|
|
760
|
-
cleaned = clean_reference_token(token)
|
|
761
|
-
if not token_resolved and cleaned and strong_unresolved_path_hint(cleaned) and len(cleaned) <= 300:
|
|
762
|
-
unresolved.append(cleaned)
|
|
763
|
-
code: list[dict[str, str]] = []
|
|
764
|
-
specs: list[dict[str, str]] = []
|
|
765
|
-
for path in sorted(set(resolved)):
|
|
766
|
-
target = specs if Path(path).suffix.lower() in DOC_EXTENSIONS else code
|
|
767
|
-
item = {"path": path, "source": "explicit"}
|
|
768
|
-
if target is specs:
|
|
769
|
-
item["kind"] = "related"
|
|
770
|
-
target.append(item)
|
|
771
|
-
resolved_set = set(resolved)
|
|
772
|
-
unresolved = [
|
|
773
|
-
token for token in unresolved
|
|
774
|
-
if not any(path.endswith("/" + token.lstrip("./")) or path == token.lstrip("./") for path in resolved_set)
|
|
775
|
-
]
|
|
776
|
-
return {"code": code, "specs": specs, "unresolved": sorted(set(unresolved))[:50]}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def inferred_relations(root: Path, values: Iterable[str], kind: str) -> list[dict[str, str]]:
|
|
780
|
-
result: list[dict[str, str]] = []
|
|
781
|
-
for value in values:
|
|
782
|
-
rel = normalize_rel_path(root, value)
|
|
783
|
-
if not (root / rel).is_file():
|
|
784
|
-
raise ValueError(f"inferred {kind} relationship does not exist: {rel}")
|
|
785
|
-
result.append({"path": rel, "source": "inferred"})
|
|
786
|
-
return sorted({item["path"]: item for item in result}.values(), key=lambda item: item["path"])
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def inferred_spec_relations(root: Path, values: Iterable[str], relation_kind: str) -> list[dict[str, str]]:
|
|
790
|
-
result = inferred_relations(root, values, "specs")
|
|
791
|
-
return [{**item, "kind": relation_kind} for item in result]
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
def merge_relations(explicit: list[dict[str, str]], inferred: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
795
|
-
merged = {(item["path"], item.get("kind", "")): item for item in inferred}
|
|
796
|
-
for item in explicit:
|
|
797
|
-
merged[(item["path"], item.get("kind", ""))] = item
|
|
798
|
-
return [merged[key] for key in sorted(merged)]
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
def relations_hash(relations: object) -> str:
|
|
802
|
-
value = relations if isinstance(relations, dict) else {}
|
|
803
|
-
code = sorted(
|
|
804
|
-
item["path"]
|
|
805
|
-
for item in value.get("code", [])
|
|
806
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
807
|
-
)
|
|
808
|
-
specs = sorted(
|
|
809
|
-
(item["path"], str(item.get("kind", "related")))
|
|
810
|
-
for item in value.get("specs", [])
|
|
811
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
812
|
-
)
|
|
813
|
-
payload = json.dumps({"code": code, "specs": specs}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
814
|
-
return sha256_bytes(payload.encode("utf-8"))
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
def current_entry_status(root: Path, entry: dict[str, Any]) -> dict[str, Any]:
|
|
818
|
-
classification = entry.get("classification")
|
|
819
|
-
if classification not in SPEC_CLASSIFICATIONS:
|
|
820
|
-
return {"status": "classified", "reasons": []}
|
|
821
|
-
rel = entry.get("path")
|
|
822
|
-
if not isinstance(rel, str):
|
|
823
|
-
return {"status": "unverified", "reasons": ["invalid source path"]}
|
|
824
|
-
try:
|
|
825
|
-
source_path = safe_project_path(root, rel)
|
|
826
|
-
except ValueError as exc:
|
|
827
|
-
return {"status": "unverified", "reasons": [str(exc)]}
|
|
828
|
-
if not source_path.is_file():
|
|
829
|
-
return {"status": "missing-source", "reasons": [rel]}
|
|
830
|
-
current_source_hash = file_hash(source_path)
|
|
831
|
-
indexed = entry.get("indexed")
|
|
832
|
-
indexed_hash = indexed.get("sourceHash") if isinstance(indexed, dict) else None
|
|
833
|
-
indexed_changed = not isinstance(indexed_hash, str) or current_source_hash != indexed_hash
|
|
834
|
-
verified = entry.get("verified")
|
|
835
|
-
if not isinstance(verified, dict) or not isinstance(verified.get("sourceHash"), str):
|
|
836
|
-
if indexed_changed:
|
|
837
|
-
return {"status": "spec-changed", "reasons": [f"source:{rel}"]}
|
|
838
|
-
return {"status": "unverified", "reasons": ["semantic verification required"]}
|
|
839
|
-
source_changed = current_source_hash != verified.get("sourceHash")
|
|
840
|
-
|
|
841
|
-
relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
|
|
842
|
-
expected_relations_hash = verified.get("relationsHash")
|
|
843
|
-
relation_map_changed = (
|
|
844
|
-
not isinstance(expected_relations_hash, str)
|
|
845
|
-
or not SHA256_RE.fullmatch(expected_relations_hash)
|
|
846
|
-
or expected_relations_hash != relations_hash(relations)
|
|
847
|
-
)
|
|
848
|
-
code_relations = relations.get("code") if isinstance(relations.get("code"), list) else []
|
|
849
|
-
expected_hashes = verified.get("codeHashes") if isinstance(verified.get("codeHashes"), dict) else {}
|
|
850
|
-
changed_inputs: list[str] = []
|
|
851
|
-
for relation in code_relations:
|
|
852
|
-
if not isinstance(relation, dict) or not isinstance(relation.get("path"), str):
|
|
853
|
-
continue
|
|
854
|
-
code_rel = relation["path"]
|
|
855
|
-
expected_hash = expected_hashes.get(code_rel)
|
|
856
|
-
if not isinstance(expected_hash, str) or not SHA256_RE.fullmatch(expected_hash):
|
|
857
|
-
changed_inputs.append(code_rel)
|
|
858
|
-
continue
|
|
859
|
-
try:
|
|
860
|
-
code_path = safe_project_path(root, code_rel)
|
|
861
|
-
except ValueError:
|
|
862
|
-
changed_inputs.append(code_rel)
|
|
863
|
-
continue
|
|
864
|
-
current_hash = file_hash(code_path)
|
|
865
|
-
if expected_hash != current_hash:
|
|
866
|
-
changed_inputs.append(code_rel)
|
|
867
|
-
|
|
868
|
-
if relation_map_changed:
|
|
869
|
-
changed_inputs.append("[relation-map]")
|
|
870
|
-
|
|
871
|
-
if source_changed and changed_inputs:
|
|
872
|
-
status = "spec+inputs-changed"
|
|
873
|
-
elif source_changed:
|
|
874
|
-
status = "spec-changed"
|
|
875
|
-
elif changed_inputs:
|
|
876
|
-
status = "inputs-changed"
|
|
877
|
-
else:
|
|
878
|
-
status = "fresh"
|
|
879
|
-
reasons = ([f"source:{rel}"] if source_changed else []) + [
|
|
880
|
-
"relations:changed" if path == "[relation-map]" else f"input:{path}"
|
|
881
|
-
for path in changed_inputs
|
|
882
|
-
]
|
|
883
|
-
return {"status": status, "reasons": reasons}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
def record_entry(args: argparse.Namespace, root: Path, state_dir: Path, state: dict[str, Any]) -> int:
|
|
887
|
-
rel = normalize_rel_path(root, args.path)
|
|
888
|
-
source_path = safe_project_path(root, rel)
|
|
889
|
-
if not source_path.is_file():
|
|
890
|
-
raise ValueError(f"source document does not exist: {rel}")
|
|
891
|
-
classification = args.classification
|
|
892
|
-
if classification in SPEC_CLASSIFICATIONS and not (args.summary and args.summary.strip()):
|
|
893
|
-
raise ValueError("--summary is required for spec/spec-like classifications")
|
|
894
|
-
source_bytes = source_path.read_bytes()
|
|
895
|
-
text = source_bytes.decode("utf-8", errors="replace")
|
|
896
|
-
source_hash = sha256_bytes(source_bytes)
|
|
897
|
-
|
|
898
|
-
entries = state.setdefault("entries", {})
|
|
899
|
-
previous = entries.get(rel) if isinstance(entries.get(rel), dict) else {}
|
|
900
|
-
if classification in SPEC_CLASSIFICATIONS:
|
|
901
|
-
if args.type is None and previous.get("type") is None:
|
|
902
|
-
raise ValueError("--type is required when first recording a primary spec")
|
|
903
|
-
if args.lifecycle is None and previous.get("lifecycle") is None:
|
|
904
|
-
raise ValueError("--lifecycle is required when first recording a primary spec")
|
|
905
|
-
explicit = explicit_relations(root, rel, text)
|
|
906
|
-
|
|
907
|
-
previous_relations = previous.get("relations") if isinstance(previous.get("relations"), dict) else {}
|
|
908
|
-
previous_code = previous_relations.get("code") if isinstance(previous_relations.get("code"), list) else []
|
|
909
|
-
previous_specs = previous_relations.get("specs") if isinstance(previous_relations.get("specs"), list) else []
|
|
910
|
-
old_inferred_code = [item for item in previous_code if isinstance(item, dict) and item.get("source") == "inferred"]
|
|
911
|
-
old_inferred_specs = [item for item in previous_specs if isinstance(item, dict) and item.get("source") == "inferred"]
|
|
912
|
-
if args.clear_code and args.code is not None:
|
|
913
|
-
raise ValueError("--clear-code cannot be combined with --code")
|
|
914
|
-
if args.clear_related_specs and args.related_spec is not None:
|
|
915
|
-
raise ValueError("--clear-related-specs cannot be combined with --related-spec")
|
|
916
|
-
if args.clear_related_specs and (args.supersedes is not None or args.superseded_by is not None):
|
|
917
|
-
raise ValueError("--clear-related-specs cannot be combined with --supersedes/--superseded-by")
|
|
918
|
-
inferred_code = (
|
|
919
|
-
[]
|
|
920
|
-
if args.clear_code
|
|
921
|
-
else inferred_relations(root, args.code, "code")
|
|
922
|
-
if args.code is not None
|
|
923
|
-
else old_inferred_code
|
|
924
|
-
)
|
|
925
|
-
if args.clear_related_specs:
|
|
926
|
-
inferred_specs = []
|
|
927
|
-
elif args.related_spec is not None or args.supersedes is not None or args.superseded_by is not None:
|
|
928
|
-
retained = [
|
|
929
|
-
item for item in old_inferred_specs
|
|
930
|
-
if item.get("kind") not in {
|
|
931
|
-
"related" if args.related_spec is not None else "",
|
|
932
|
-
"supersedes" if args.supersedes is not None else "",
|
|
933
|
-
"superseded-by" if args.superseded_by is not None else "",
|
|
934
|
-
}
|
|
935
|
-
]
|
|
936
|
-
inferred_specs = [
|
|
937
|
-
*retained,
|
|
938
|
-
*inferred_spec_relations(root, args.related_spec or [], "related"),
|
|
939
|
-
*inferred_spec_relations(root, args.supersedes or [], "supersedes"),
|
|
940
|
-
*inferred_spec_relations(root, args.superseded_by or [], "superseded-by"),
|
|
941
|
-
]
|
|
942
|
-
else:
|
|
943
|
-
inferred_specs = old_inferred_specs
|
|
944
|
-
relations = {
|
|
945
|
-
"code": merge_relations(explicit["code"], inferred_code),
|
|
946
|
-
"specs": merge_relations(explicit["specs"], inferred_specs),
|
|
947
|
-
}
|
|
948
|
-
if classification not in SPEC_CLASSIFICATIONS:
|
|
949
|
-
relations = {"code": [], "specs": []}
|
|
950
|
-
|
|
951
|
-
topics = sorted(set(args.topic if args.topic is not None else previous.get("topics", [])))
|
|
952
|
-
if classification not in SPEC_CLASSIFICATIONS:
|
|
953
|
-
topics = sorted(set(args.topic or []))
|
|
954
|
-
lifecycle = (
|
|
955
|
-
args.lifecycle or previous.get("lifecycle", "unknown")
|
|
956
|
-
if classification in SPEC_CLASSIFICATIONS
|
|
957
|
-
else "unknown"
|
|
958
|
-
)
|
|
959
|
-
entry = {
|
|
960
|
-
"path": rel,
|
|
961
|
-
"title": document_title(text, rel),
|
|
962
|
-
"classification": classification,
|
|
963
|
-
"type": (args.type or previous.get("type", "unknown")) if classification in SPEC_CLASSIFICATIONS else "unknown",
|
|
964
|
-
"lifecycle": lifecycle,
|
|
965
|
-
"confidence": args.confidence or previous.get("confidence", "unknown"),
|
|
966
|
-
"summary": (args.summary.strip() if args.summary is not None else previous.get("summary", "")),
|
|
967
|
-
"topics": topics,
|
|
968
|
-
"relations": relations,
|
|
969
|
-
"unresolvedReferences": explicit.get("unresolved", []) if classification in SPEC_CLASSIFICATIONS else [],
|
|
970
|
-
"indexed": {
|
|
971
|
-
"at": utc_now(),
|
|
972
|
-
"sourceHash": source_hash,
|
|
973
|
-
},
|
|
974
|
-
}
|
|
975
|
-
if classification in SPEC_CLASSIFICATIONS and isinstance(previous.get("verified"), dict):
|
|
976
|
-
entry["verified"] = previous["verified"]
|
|
977
|
-
if classification not in SPEC_CLASSIFICATIONS:
|
|
978
|
-
entry["summary"] = args.summary.strip() if args.summary else ""
|
|
979
|
-
entries[rel] = entry
|
|
980
|
-
save_state(state_dir, state)
|
|
981
|
-
print(f"recorded {classification}: {rel}")
|
|
982
|
-
return 0
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
def verify_entry(path_value: str, root: Path, state_dir: Path, state: dict[str, Any]) -> int:
|
|
986
|
-
rel = normalize_rel_path(root, path_value)
|
|
987
|
-
entries = state.get("entries")
|
|
988
|
-
if not isinstance(entries, dict) or not isinstance(entries.get(rel), dict):
|
|
989
|
-
raise ValueError(f"state entry does not exist: {rel}")
|
|
990
|
-
entry = entries[rel]
|
|
991
|
-
if entry.get("classification") not in SPEC_CLASSIFICATIONS:
|
|
992
|
-
raise ValueError(f"only primary specs can be semantically verified: {rel}")
|
|
993
|
-
source_path = safe_project_path(root, rel)
|
|
994
|
-
if not source_path.is_file():
|
|
995
|
-
raise ValueError(f"source document does not exist: {rel}")
|
|
996
|
-
source_hash = file_hash(source_path)
|
|
997
|
-
if source_hash is None:
|
|
998
|
-
raise ValueError(f"failed to hash source document: {rel}")
|
|
999
|
-
relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
|
|
1000
|
-
code_relations = relations.get("code") if isinstance(relations.get("code"), list) else []
|
|
1001
|
-
code_hashes: dict[str, str] = {}
|
|
1002
|
-
for item in code_relations:
|
|
1003
|
-
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
1004
|
-
continue
|
|
1005
|
-
relation_hash = file_hash(safe_project_path(root, item["path"]))
|
|
1006
|
-
if relation_hash is None:
|
|
1007
|
-
raise ValueError(f"tracked input is missing or unreadable: {item['path']}")
|
|
1008
|
-
code_hashes[item["path"]] = relation_hash
|
|
1009
|
-
entry["indexed"] = {"at": utc_now(), "sourceHash": source_hash}
|
|
1010
|
-
entry["verified"] = {
|
|
1011
|
-
"at": utc_now(),
|
|
1012
|
-
"sourceHash": source_hash,
|
|
1013
|
-
"codeHashes": code_hashes,
|
|
1014
|
-
"relationsHash": relations_hash(relations),
|
|
1015
|
-
}
|
|
1016
|
-
entries[rel] = entry
|
|
1017
|
-
save_state(state_dir, state)
|
|
1018
|
-
unresolved = entry.get("unresolvedReferences") if isinstance(entry.get("unresolvedReferences"), list) else []
|
|
1019
|
-
suffix = f" ({len(unresolved)} unresolved path hints remain)" if unresolved else ""
|
|
1020
|
-
print(f"verified: {rel}{suffix}")
|
|
1021
|
-
return 0
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
def relate_entry(args: argparse.Namespace, root: Path, state_dir: Path, state: dict[str, Any]) -> int:
|
|
1025
|
-
rel = normalize_rel_path(root, args.path)
|
|
1026
|
-
entries = state.get("entries")
|
|
1027
|
-
if not isinstance(entries, dict) or not isinstance(entries.get(rel), dict):
|
|
1028
|
-
raise ValueError(f"state entry does not exist: {rel}")
|
|
1029
|
-
entry = entries[rel]
|
|
1030
|
-
if entry.get("classification") not in SPEC_CLASSIFICATIONS:
|
|
1031
|
-
raise ValueError(f"only primary specs can have tracked relations: {rel}")
|
|
1032
|
-
|
|
1033
|
-
relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
|
|
1034
|
-
before = relations_hash(relations)
|
|
1035
|
-
source_path = safe_project_path(root, rel)
|
|
1036
|
-
if not source_path.is_file():
|
|
1037
|
-
raise ValueError(f"source document does not exist: {rel}")
|
|
1038
|
-
explicit = explicit_relations(root, rel, source_path.read_text(encoding="utf-8", errors="replace"))
|
|
1039
|
-
code = [
|
|
1040
|
-
item for item in relations.get("code", [])
|
|
1041
|
-
if isinstance(item, dict) and item.get("source") == "inferred"
|
|
1042
|
-
]
|
|
1043
|
-
specs = [
|
|
1044
|
-
item for item in relations.get("specs", [])
|
|
1045
|
-
if isinstance(item, dict) and item.get("source") == "inferred"
|
|
1046
|
-
]
|
|
1047
|
-
|
|
1048
|
-
for value in args.add_code or []:
|
|
1049
|
-
for item in inferred_relations(root, [value], "code"):
|
|
1050
|
-
if not any(existing.get("path") == item["path"] for existing in code):
|
|
1051
|
-
code.append(item)
|
|
1052
|
-
for value in args.remove_code or []:
|
|
1053
|
-
target = normalize_rel_path(root, value)
|
|
1054
|
-
code = [
|
|
1055
|
-
item for item in code
|
|
1056
|
-
if not (item.get("path") == target and item.get("source") == "inferred")
|
|
1057
|
-
]
|
|
1058
|
-
|
|
1059
|
-
spec_ops = [
|
|
1060
|
-
(args.add_related_spec or [], "related", True),
|
|
1061
|
-
(args.add_supersedes or [], "supersedes", True),
|
|
1062
|
-
(args.add_superseded_by or [], "superseded-by", True),
|
|
1063
|
-
(args.remove_related_spec or [], "related", False),
|
|
1064
|
-
(args.remove_supersedes or [], "supersedes", False),
|
|
1065
|
-
(args.remove_superseded_by or [], "superseded-by", False),
|
|
1066
|
-
]
|
|
1067
|
-
for values, kind, adding in spec_ops:
|
|
1068
|
-
for value in values:
|
|
1069
|
-
target = normalize_rel_path(root, value)
|
|
1070
|
-
if adding:
|
|
1071
|
-
item = inferred_spec_relations(root, [value], kind)[0]
|
|
1072
|
-
if not any(existing.get("path") == target and existing.get("kind", "related") == kind for existing in specs):
|
|
1073
|
-
specs.append(item)
|
|
1074
|
-
else:
|
|
1075
|
-
specs = [
|
|
1076
|
-
item for item in specs
|
|
1077
|
-
if not (
|
|
1078
|
-
item.get("path") == target
|
|
1079
|
-
and item.get("kind", "related") == kind
|
|
1080
|
-
and item.get("source") == "inferred"
|
|
1081
|
-
)
|
|
1082
|
-
]
|
|
1083
|
-
|
|
1084
|
-
code = sorted(code, key=lambda item: (str(item.get("path", "")), str(item.get("source", ""))))
|
|
1085
|
-
specs = sorted(specs, key=lambda item: (str(item.get("path", "")), str(item.get("kind", "related")), str(item.get("source", ""))))
|
|
1086
|
-
updated = {
|
|
1087
|
-
"code": merge_relations(explicit["code"], code),
|
|
1088
|
-
"specs": merge_relations(explicit["specs"], specs),
|
|
1089
|
-
}
|
|
1090
|
-
after = relations_hash(updated)
|
|
1091
|
-
if before == after:
|
|
1092
|
-
print(f"unchanged relations: {rel}")
|
|
1093
|
-
return 0
|
|
1094
|
-
entry["relations"] = updated
|
|
1095
|
-
entry["unresolvedReferences"] = explicit.get("unresolved", [])
|
|
1096
|
-
entries[rel] = entry
|
|
1097
|
-
save_state(state_dir, state)
|
|
1098
|
-
print(f"updated relations: {rel} — {current_entry_status(root, entry)['status']}")
|
|
1099
|
-
return 0
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
def markdown_inline_list(values: list[str], maximum: int = 8) -> str:
|
|
1103
|
-
shown = values[:maximum]
|
|
1104
|
-
rendered = ", ".join(f"`{value}`" for value in shown)
|
|
1105
|
-
if len(values) > maximum:
|
|
1106
|
-
rendered += f", +{len(values) - maximum} more"
|
|
1107
|
-
return rendered
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
def compact_text(value: object, maximum: int = 180) -> str:
|
|
1111
|
-
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
|
1112
|
-
if len(text) <= maximum:
|
|
1113
|
-
return text
|
|
1114
|
-
return text[: maximum - 1].rstrip() + "…"
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
def lifecycle_rank(value: object) -> int:
|
|
1118
|
-
return {
|
|
1119
|
-
"active": 0,
|
|
1120
|
-
"proposed": 1,
|
|
1121
|
-
"unknown": 2,
|
|
1122
|
-
"historical": 3,
|
|
1123
|
-
"superseded": 4,
|
|
1124
|
-
}.get(str(value), 5)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
SEARCH_STOPWORDS = {
|
|
1128
|
-
"a", "an", "and", "are", "do", "does", "for", "how", "in", "is", "of", "on", "or", "the", "to", "what", "where", "why",
|
|
1129
|
-
"а", "в", "где", "и", "как", "ли", "на", "почему", "что",
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
RELATION_GENERIC_TERMS = {
|
|
1133
|
-
"agent",
|
|
1134
|
-
"app",
|
|
1135
|
-
"config",
|
|
1136
|
-
"index",
|
|
1137
|
-
"model",
|
|
1138
|
-
"prompt",
|
|
1139
|
-
"result",
|
|
1140
|
-
"session",
|
|
1141
|
-
"spec",
|
|
1142
|
-
"state",
|
|
1143
|
-
"test",
|
|
1144
|
-
"tool",
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
def normalize_search_text(value: object) -> str:
|
|
1149
|
-
return re.sub(r"\s+", " ", unicodedata.normalize("NFKC", str(value or "")).casefold()).strip()
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
def search_terms(value: object) -> set[str]:
|
|
1153
|
-
normalized = normalize_search_text(value)
|
|
1154
|
-
result: set[str] = set()
|
|
1155
|
-
for token in re.findall(r"[\w][\w.+@:/-]*", normalized, flags=re.UNICODE):
|
|
1156
|
-
if len(token) > 1 and token not in SEARCH_STOPWORDS:
|
|
1157
|
-
result.add(token)
|
|
1158
|
-
# Keep the compound for exact/prefix matching, but also expose path and
|
|
1159
|
-
# identifier components so `session cancellation` can match
|
|
1160
|
-
# `session-cancellation` and `model usage` can match `model-usage.ts`.
|
|
1161
|
-
for part in re.split(r"[/_-]+", token):
|
|
1162
|
-
if len(part) > 1 and part not in SEARCH_STOPWORDS:
|
|
1163
|
-
result.add(part)
|
|
1164
|
-
return result
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
def search_term_matches(query_term: str, field_term: str) -> bool:
|
|
1168
|
-
if query_term == field_term:
|
|
1169
|
-
return True
|
|
1170
|
-
if len(query_term) < 4 or len(field_term) < 4:
|
|
1171
|
-
return False
|
|
1172
|
-
return field_term.startswith(query_term) or query_term.startswith(field_term)
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
def relation_paths(entry: dict[str, Any], kind: str, spec_kinds: set[str] | None = None) -> list[str]:
|
|
1176
|
-
relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
|
|
1177
|
-
values = relations.get(kind) if isinstance(relations.get(kind), list) else []
|
|
1178
|
-
return [
|
|
1179
|
-
str(item["path"])
|
|
1180
|
-
for item in values
|
|
1181
|
-
if isinstance(item, dict)
|
|
1182
|
-
and isinstance(item.get("path"), str)
|
|
1183
|
-
and (spec_kinds is None or str(item.get("kind", "related")) in spec_kinds)
|
|
1184
|
-
]
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
def relation_search_terms(paths: Iterable[str]) -> set[str]:
|
|
1188
|
-
result: set[str] = set()
|
|
1189
|
-
for value in paths:
|
|
1190
|
-
name = Path(value).name
|
|
1191
|
-
if not name:
|
|
1192
|
-
continue
|
|
1193
|
-
result.update(search_terms(name))
|
|
1194
|
-
stem = Path(name).stem
|
|
1195
|
-
if len(stem) > 1:
|
|
1196
|
-
result.add(normalize_search_text(stem))
|
|
1197
|
-
return result
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
def score_search_query(entry: dict[str, Any], query: str) -> tuple[float, list[str], bool, float, float]:
|
|
1201
|
-
normalized_query = normalize_search_text(query)
|
|
1202
|
-
if not normalized_query:
|
|
1203
|
-
return 0.0, [], False, 0.0, 0.0
|
|
1204
|
-
query_terms = search_terms(normalized_query)
|
|
1205
|
-
title = str(entry.get("title", ""))
|
|
1206
|
-
path = str(entry.get("path", ""))
|
|
1207
|
-
summary = str(entry.get("summary", ""))
|
|
1208
|
-
topics = [str(item) for item in entry.get("topics", []) if str(item).strip()]
|
|
1209
|
-
code_relations = relation_paths(entry, "code")
|
|
1210
|
-
spec_relations = relation_paths(entry, "specs", {"related"})
|
|
1211
|
-
supersedes = relation_paths(entry, "specs", {"supersedes"})
|
|
1212
|
-
superseded_by = relation_paths(entry, "specs", {"superseded-by"})
|
|
1213
|
-
supersession = supersedes + superseded_by
|
|
1214
|
-
|
|
1215
|
-
fields = {
|
|
1216
|
-
"title": normalize_search_text(title),
|
|
1217
|
-
"topics": normalize_search_text(" ".join(topics)),
|
|
1218
|
-
"path": normalize_search_text(path),
|
|
1219
|
-
"summary": normalize_search_text(summary),
|
|
1220
|
-
"relations.code": normalize_search_text(" ".join(code_relations)),
|
|
1221
|
-
"relations.specs": normalize_search_text(" ".join(spec_relations)),
|
|
1222
|
-
"relations.supersession": normalize_search_text(" ".join(supersession)),
|
|
1223
|
-
}
|
|
1224
|
-
term_fields = {
|
|
1225
|
-
"title": search_terms(fields["title"]),
|
|
1226
|
-
"topics": search_terms(fields["topics"]),
|
|
1227
|
-
"path": search_terms(fields["path"]),
|
|
1228
|
-
"summary": search_terms(fields["summary"]),
|
|
1229
|
-
# Relations are routing hints. Score only their concrete leaf names,
|
|
1230
|
-
# not generic directory segments such as `src`, `session`, or `dcp`.
|
|
1231
|
-
# Exact full relation paths are handled separately above.
|
|
1232
|
-
"relations.code": relation_search_terms(code_relations),
|
|
1233
|
-
"relations.specs": relation_search_terms(spec_relations),
|
|
1234
|
-
"relations.supersession": relation_search_terms(supersession),
|
|
1235
|
-
}
|
|
1236
|
-
weights = {
|
|
1237
|
-
"title": 8.0,
|
|
1238
|
-
"topics": 6.0,
|
|
1239
|
-
"relations.code": 6.0,
|
|
1240
|
-
"relations.specs": 5.0,
|
|
1241
|
-
"relations.supersession": 5.0,
|
|
1242
|
-
"path": 4.0,
|
|
1243
|
-
"summary": 2.0,
|
|
1244
|
-
}
|
|
1245
|
-
score = 0.0
|
|
1246
|
-
matches: list[str] = []
|
|
1247
|
-
exact_path_match = False
|
|
1248
|
-
matched_query_terms: set[str] = set()
|
|
1249
|
-
semantic_matched_terms: set[str] = set()
|
|
1250
|
-
|
|
1251
|
-
if normalized_query == fields["path"]:
|
|
1252
|
-
score += 50.0
|
|
1253
|
-
matches.append("exact:spec-path")
|
|
1254
|
-
exact_path_match = True
|
|
1255
|
-
matched_query_terms.update(query_terms)
|
|
1256
|
-
semantic_matched_terms.update(query_terms)
|
|
1257
|
-
for relation_kind, paths, exact_weight in (
|
|
1258
|
-
("relations.code", code_relations, 40.0),
|
|
1259
|
-
("relations.specs", spec_relations, 36.0),
|
|
1260
|
-
("relations.supersession", supersession, 34.0),
|
|
1261
|
-
):
|
|
1262
|
-
if any(normalized_query == normalize_search_text(value) for value in paths):
|
|
1263
|
-
score += exact_weight
|
|
1264
|
-
matches.append(f"exact:{relation_kind}")
|
|
1265
|
-
exact_path_match = True
|
|
1266
|
-
matched_query_terms.update(query_terms)
|
|
1267
|
-
|
|
1268
|
-
phrase_weights = {
|
|
1269
|
-
"title": 12.0,
|
|
1270
|
-
"topics": 10.0,
|
|
1271
|
-
"relations.code": 18.0,
|
|
1272
|
-
"relations.specs": 14.0,
|
|
1273
|
-
"relations.supersession": 14.0,
|
|
1274
|
-
"path": 12.0,
|
|
1275
|
-
"summary": 4.0,
|
|
1276
|
-
}
|
|
1277
|
-
if len(normalized_query) >= 4:
|
|
1278
|
-
for name, value in fields.items():
|
|
1279
|
-
if normalized_query and normalized_query in value:
|
|
1280
|
-
score += phrase_weights[name]
|
|
1281
|
-
matches.append(f"phrase:{name}")
|
|
1282
|
-
matched_query_terms.update(query_terms)
|
|
1283
|
-
if not name.startswith("relations."):
|
|
1284
|
-
semantic_matched_terms.update(query_terms)
|
|
1285
|
-
|
|
1286
|
-
for query_term in query_terms:
|
|
1287
|
-
for name, field_terms in term_fields.items():
|
|
1288
|
-
if name.startswith("relations.") and query_term in RELATION_GENERIC_TERMS:
|
|
1289
|
-
continue
|
|
1290
|
-
if any(search_term_matches(query_term, field_term) for field_term in field_terms):
|
|
1291
|
-
score += weights[name]
|
|
1292
|
-
matches.append(f"term:{query_term}:{name}")
|
|
1293
|
-
matched_query_terms.add(query_term)
|
|
1294
|
-
if not name.startswith("relations."):
|
|
1295
|
-
semantic_matched_terms.add(query_term)
|
|
1296
|
-
|
|
1297
|
-
coverage = len(matched_query_terms) / len(query_terms) if query_terms else (1.0 if exact_path_match else 0.0)
|
|
1298
|
-
semantic_coverage = len(semantic_matched_terms) / len(query_terms) if query_terms else 0.0
|
|
1299
|
-
# Prefer entries that explain the whole intent in title/topics/path/summary,
|
|
1300
|
-
# not entries that accumulate a large score from one repeated domain word.
|
|
1301
|
-
# Relation matches still provide strong exact component/path routing above.
|
|
1302
|
-
score += semantic_coverage * 12.0
|
|
1303
|
-
if semantic_coverage >= 0.999 and len(query_terms) >= 2:
|
|
1304
|
-
score += 8.0
|
|
1305
|
-
return score, sorted(set(matches)), exact_path_match, round(coverage, 3), round(semantic_coverage, 3)
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
def search_entries(root: Path, state: dict[str, Any], queries: list[str], limit: int, include_secondary: bool) -> list[dict[str, Any]]:
|
|
1309
|
-
normalized_queries: list[str] = []
|
|
1310
|
-
for query in queries:
|
|
1311
|
-
normalized = normalize_search_text(query)
|
|
1312
|
-
if normalized and normalized not in normalized_queries:
|
|
1313
|
-
normalized_queries.append(normalized)
|
|
1314
|
-
if not normalized_queries:
|
|
1315
|
-
raise ValueError("search query must not be empty")
|
|
1316
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
1317
|
-
results: list[dict[str, Any]] = []
|
|
1318
|
-
for entry in entries.values():
|
|
1319
|
-
if not isinstance(entry, dict):
|
|
1320
|
-
continue
|
|
1321
|
-
classification = entry.get("classification")
|
|
1322
|
-
if classification not in SPEC_CLASSIFICATIONS and not (include_secondary and classification == "design-only"):
|
|
1323
|
-
continue
|
|
1324
|
-
query_matches: list[dict[str, Any]] = []
|
|
1325
|
-
for query in normalized_queries:
|
|
1326
|
-
query_score, matches, exact_path_match, coverage, semantic_coverage = score_search_query(entry, query)
|
|
1327
|
-
if query_score > 0:
|
|
1328
|
-
query_matches.append(
|
|
1329
|
-
{
|
|
1330
|
-
"query": query,
|
|
1331
|
-
"score": round(query_score, 2),
|
|
1332
|
-
"matches": matches,
|
|
1333
|
-
"exactPath": exact_path_match,
|
|
1334
|
-
"coverage": coverage,
|
|
1335
|
-
"semanticCoverage": semantic_coverage,
|
|
1336
|
-
}
|
|
1337
|
-
)
|
|
1338
|
-
if not query_matches:
|
|
1339
|
-
continue
|
|
1340
|
-
query_matches.sort(key=lambda item: -float(item["score"]))
|
|
1341
|
-
best_query_score = float(query_matches[0]["score"])
|
|
1342
|
-
supporting_score = min(10.0, sum(float(item["score"]) for item in query_matches[1:]) * 0.15)
|
|
1343
|
-
score = best_query_score + supporting_score
|
|
1344
|
-
lifecycle = entry.get("lifecycle", "unknown")
|
|
1345
|
-
score += {"active": 3.0, "proposed": 1.5, "unknown": 0.0, "historical": -1.0, "superseded": -3.0}.get(str(lifecycle), 0.0)
|
|
1346
|
-
if classification == "design-only":
|
|
1347
|
-
score -= 2.0
|
|
1348
|
-
status = current_entry_status(root, entry) if classification in SPEC_CLASSIFICATIONS else {"status": "secondary", "reasons": []}
|
|
1349
|
-
title = str(entry.get("title", ""))
|
|
1350
|
-
path = str(entry.get("path", ""))
|
|
1351
|
-
summary = str(entry.get("summary", ""))
|
|
1352
|
-
topics = [str(item) for item in entry.get("topics", []) if str(item).strip()]
|
|
1353
|
-
results.append(
|
|
1354
|
-
{
|
|
1355
|
-
"path": path,
|
|
1356
|
-
"title": title,
|
|
1357
|
-
"classification": classification,
|
|
1358
|
-
"type": entry.get("type", "unknown"),
|
|
1359
|
-
"lifecycle": lifecycle,
|
|
1360
|
-
"status": status["status"],
|
|
1361
|
-
"score": round(score, 2),
|
|
1362
|
-
"topics": topics,
|
|
1363
|
-
"summary": summary,
|
|
1364
|
-
"queryMatches": query_matches,
|
|
1365
|
-
}
|
|
1366
|
-
)
|
|
1367
|
-
results.sort(key=lambda item: (-float(item["score"]), lifecycle_rank(item["lifecycle"]), str(item["title"]).lower(), str(item["path"])))
|
|
1368
|
-
return results[: max(1, limit)]
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
def search_quality(results: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1372
|
-
if not results:
|
|
1373
|
-
return {
|
|
1374
|
-
"confidence": "none",
|
|
1375
|
-
"catalogFallbackRecommended": True,
|
|
1376
|
-
"semanticRerankRecommended": False,
|
|
1377
|
-
"reason": "no lexical/relation match",
|
|
1378
|
-
}
|
|
1379
|
-
top = float(results[0]["score"])
|
|
1380
|
-
second = float(results[1]["score"]) if len(results) > 1 else None
|
|
1381
|
-
margin = top - second if second is not None else top
|
|
1382
|
-
exact = any(bool(item.get("exactPath")) for item in results[0].get("queryMatches", []))
|
|
1383
|
-
top_coverage = max((float(item.get("coverage", 0.0)) for item in results[0].get("queryMatches", [])), default=0.0)
|
|
1384
|
-
top_semantic_coverage = max((float(item.get("semanticCoverage", 0.0)) for item in results[0].get("queryMatches", [])), default=0.0)
|
|
1385
|
-
if exact or (top >= 20.0 and top_semantic_coverage >= 0.66):
|
|
1386
|
-
confidence = "high"
|
|
1387
|
-
elif top >= 12.0 and top_semantic_coverage >= 0.4:
|
|
1388
|
-
confidence = "medium"
|
|
1389
|
-
else:
|
|
1390
|
-
confidence = "low"
|
|
1391
|
-
return {
|
|
1392
|
-
"confidence": confidence,
|
|
1393
|
-
"catalogFallbackRecommended": confidence == "low",
|
|
1394
|
-
# Candidate generation is intentionally lexical/relation based. When
|
|
1395
|
-
# more than one candidate survives, the current LLM should always do
|
|
1396
|
-
# the cheap semantic choice from compact metadata instead of trusting
|
|
1397
|
-
# the numeric rank as semantic truth.
|
|
1398
|
-
"semanticRerankRecommended": len(results) > 1,
|
|
1399
|
-
"topScore": round(top, 2),
|
|
1400
|
-
"topMargin": round(margin, 2),
|
|
1401
|
-
"topCoverage": round(top_coverage, 3),
|
|
1402
|
-
"topSemanticCoverage": round(top_semantic_coverage, 3),
|
|
1403
|
-
"reason": "exact relation/path match" if exact else "weighted metadata/relation match",
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
def render_index(root: Path, state_dir: Path, state: dict[str, Any]) -> str:
|
|
1408
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
1409
|
-
specs = [entry for entry in entries.values() if isinstance(entry, dict) and entry.get("classification") in SPEC_CLASSIFICATIONS]
|
|
1410
|
-
specs.sort(key=lambda item: (lifecycle_rank(item.get("lifecycle")), str(item.get("title", "")).lower(), str(item.get("path", ""))))
|
|
1411
|
-
statuses = {entry["path"]: current_entry_status(root, entry) for entry in specs if isinstance(entry.get("path"), str)}
|
|
1412
|
-
|
|
1413
|
-
lines = [
|
|
1414
|
-
"# Spec Wiki",
|
|
1415
|
-
"",
|
|
1416
|
-
"> Generated routing index. Primary specs remain the source of truth.",
|
|
1417
|
-
"> Prefer `spec_wiki.py search <query>` for retrieval; `fresh` exists only after explicit semantic verification.",
|
|
1418
|
-
"",
|
|
1419
|
-
]
|
|
1420
|
-
unverified_current = [
|
|
1421
|
-
entry for entry in specs
|
|
1422
|
-
if entry.get("lifecycle", "unknown") not in {"historical", "superseded"}
|
|
1423
|
-
and statuses.get(entry["path"], {}).get("status") == "unverified"
|
|
1424
|
-
]
|
|
1425
|
-
if unverified_current:
|
|
1426
|
-
lines.extend(
|
|
1427
|
-
[
|
|
1428
|
-
f"> {len(unverified_current)} current/proposed primary spec(s) are indexed but not semantically verified yet.",
|
|
1429
|
-
"",
|
|
1430
|
-
]
|
|
1431
|
-
)
|
|
1432
|
-
stale = [
|
|
1433
|
-
(entry, statuses.get(entry["path"], {"status": "unverified", "reasons": []}))
|
|
1434
|
-
for entry in specs
|
|
1435
|
-
if entry.get("lifecycle", "unknown") not in {"historical", "superseded"}
|
|
1436
|
-
and statuses.get(entry["path"], {}).get("status") not in {"fresh", "unverified"}
|
|
1437
|
-
]
|
|
1438
|
-
if stale:
|
|
1439
|
-
lines.extend(["## Needs review", ""])
|
|
1440
|
-
for entry, status in stale:
|
|
1441
|
-
reason_text = "; ".join(status.get("reasons", [])[:4])
|
|
1442
|
-
suffix = f" — {reason_text}" if reason_text else ""
|
|
1443
|
-
lines.append(f"- `{entry['path']}` — **{status['status']}**{suffix}")
|
|
1444
|
-
lines.append("")
|
|
1445
|
-
|
|
1446
|
-
lines.extend(["## Current / proposed primary specs", ""])
|
|
1447
|
-
if not specs:
|
|
1448
|
-
lines.extend(["_No primary specs have been semantically classified yet._", ""])
|
|
1449
|
-
for entry in [item for item in specs if item.get("lifecycle", "unknown") not in {"historical", "superseded"}]:
|
|
1450
|
-
status = statuses.get(entry["path"], {"status": "unverified"})
|
|
1451
|
-
topics = [str(item) for item in entry.get("topics", []) if str(item).strip()]
|
|
1452
|
-
topic_text = ",".join(topics[:6]) or "-"
|
|
1453
|
-
lines.append(
|
|
1454
|
-
f"- **{entry.get('title') or entry['path']}** — `{entry['path']}` · "
|
|
1455
|
-
f"{entry.get('type', 'unknown')}/{entry.get('lifecycle', 'unknown')} · "
|
|
1456
|
-
f"**{status.get('status', 'unverified')}** · {topic_text} — {compact_text(entry.get('summary', ''), 180)}"
|
|
1457
|
-
)
|
|
1458
|
-
archived = [item for item in specs if item.get("lifecycle", "unknown") in {"historical", "superseded"}]
|
|
1459
|
-
if archived:
|
|
1460
|
-
lines.extend(["", "## Historical / superseded primary specs", ""])
|
|
1461
|
-
for entry in archived:
|
|
1462
|
-
lines.append(
|
|
1463
|
-
f"- **{entry.get('title') or entry['path']}** — `{entry['path']}` · "
|
|
1464
|
-
f"{entry.get('type', 'unknown')}/{entry.get('lifecycle', 'unknown')} — "
|
|
1465
|
-
f"{compact_text(entry.get('summary', ''), 140)}"
|
|
1466
|
-
)
|
|
1467
|
-
secondary = [
|
|
1468
|
-
entry for entry in entries.values()
|
|
1469
|
-
if isinstance(entry, dict) and entry.get("classification") == "design-only"
|
|
1470
|
-
]
|
|
1471
|
-
if secondary:
|
|
1472
|
-
secondary.sort(key=lambda item: (str(item.get("title", "")).lower(), str(item.get("path", ""))))
|
|
1473
|
-
lines.extend(["", "## Secondary design / decision references", ""])
|
|
1474
|
-
for entry in secondary:
|
|
1475
|
-
lines.append(f"- **{entry.get('title') or entry['path']}** — `{entry['path']}`")
|
|
1476
|
-
lines.append("")
|
|
1477
|
-
return "\n".join(lines).rstrip() + "\n"
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
def audit_payload(root: Path, state_dir: Path, config: dict[str, Any], state: dict[str, Any], candidate_limit: int) -> dict[str, Any]:
|
|
1481
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
1482
|
-
specs = [entry for entry in entries.values() if isinstance(entry, dict) and entry.get("classification") in SPEC_CLASSIFICATIONS]
|
|
1483
|
-
statuses = []
|
|
1484
|
-
for entry in sorted(specs, key=lambda item: str(item.get("path", ""))):
|
|
1485
|
-
status = current_entry_status(root, entry)
|
|
1486
|
-
statuses.append({"path": entry.get("path"), "lifecycle": entry.get("lifecycle", "unknown"), **status})
|
|
1487
|
-
candidates = discover_candidates(root, state_dir, config, state, include_all=False)
|
|
1488
|
-
return {
|
|
1489
|
-
"schemaVersion": SCHEMA_VERSION,
|
|
1490
|
-
"primarySpecCount": len(specs),
|
|
1491
|
-
"currentPrimarySpecCount": sum(1 for entry in specs if entry.get("lifecycle", "unknown") not in {"historical", "superseded"}),
|
|
1492
|
-
"freshCount": sum(1 for item in statuses if item["status"] == "fresh"),
|
|
1493
|
-
"unverifiedCount": sum(1 for item in statuses if item["status"] == "unverified"),
|
|
1494
|
-
"needsReviewCount": sum(
|
|
1495
|
-
1 for item in statuses
|
|
1496
|
-
if item["lifecycle"] not in {"historical", "superseded"}
|
|
1497
|
-
and item["status"] not in {"fresh", "unverified"}
|
|
1498
|
-
),
|
|
1499
|
-
"unresolvedReferenceCount": sum(
|
|
1500
|
-
len(entry.get("unresolvedReferences", []))
|
|
1501
|
-
for entry in specs if isinstance(entry.get("unresolvedReferences"), list)
|
|
1502
|
-
),
|
|
1503
|
-
"statuses": statuses,
|
|
1504
|
-
"candidateCount": len(candidates),
|
|
1505
|
-
"candidates": candidates[:candidate_limit],
|
|
1506
|
-
"candidateListTruncated": len(candidates) > candidate_limit,
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
def print_audit(payload: dict[str, Any]) -> None:
|
|
1511
|
-
print(
|
|
1512
|
-
f"primary specs: {payload['primarySpecCount']} ({payload['currentPrimarySpecCount']} current/proposed) | "
|
|
1513
|
-
f"fresh: {payload['freshCount']} | unverified: {payload['unverifiedCount']} | "
|
|
1514
|
-
f"needs review: {payload['needsReviewCount']} | unresolved refs: {payload['unresolvedReferenceCount']} | "
|
|
1515
|
-
f"new/changed candidates: {payload['candidateCount']}"
|
|
1516
|
-
)
|
|
1517
|
-
for item in payload["statuses"]:
|
|
1518
|
-
if item["status"] == "fresh" or item.get("lifecycle") in {"historical", "superseded"}:
|
|
1519
|
-
continue
|
|
1520
|
-
reasons = ", ".join(item.get("reasons", [])[:4])
|
|
1521
|
-
print(f" {item['status']:24} {item['path']}{' — ' + reasons if reasons else ''}")
|
|
1522
|
-
if payload["candidates"]:
|
|
1523
|
-
print("candidates:")
|
|
1524
|
-
for item in payload["candidates"]:
|
|
1525
|
-
known = f" known={item['knownClassification']}" if item.get("knownClassification") else ""
|
|
1526
|
-
print(f" score={item['score']:>3} {item['roleHint']:14} {item['path']}{known}")
|
|
1527
|
-
if payload["candidateListTruncated"]:
|
|
1528
|
-
print(" ... candidate list truncated")
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
def validate_state(root: Path, state: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
1532
|
-
errors: list[str] = []
|
|
1533
|
-
warnings: list[str] = []
|
|
1534
|
-
if state.get("schemaVersion") != SCHEMA_VERSION:
|
|
1535
|
-
errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
|
|
1536
|
-
entries = state.get("entries")
|
|
1537
|
-
if not isinstance(entries, dict):
|
|
1538
|
-
return errors + ["entries must be an object"], warnings
|
|
1539
|
-
for key, entry in entries.items():
|
|
1540
|
-
if not isinstance(entry, dict):
|
|
1541
|
-
errors.append(f"{key}: entry must be an object")
|
|
1542
|
-
continue
|
|
1543
|
-
path = entry.get("path")
|
|
1544
|
-
if path != key:
|
|
1545
|
-
errors.append(f"{key}: entry.path must equal state key")
|
|
1546
|
-
try:
|
|
1547
|
-
normalized = normalize_rel_path(root, str(path))
|
|
1548
|
-
if normalized != path:
|
|
1549
|
-
errors.append(f"{key}: path is not normalized")
|
|
1550
|
-
except ValueError as exc:
|
|
1551
|
-
errors.append(f"{key}: {exc}")
|
|
1552
|
-
classification = entry.get("classification")
|
|
1553
|
-
if classification not in CLASSIFICATIONS:
|
|
1554
|
-
errors.append(f"{key}: invalid classification {classification!r}")
|
|
1555
|
-
if classification in SPEC_CLASSIFICATIONS and not str(entry.get("summary", "")).strip():
|
|
1556
|
-
errors.append(f"{key}: primary spec requires non-empty summary")
|
|
1557
|
-
if entry.get("type", "unknown") not in SPEC_TYPES:
|
|
1558
|
-
errors.append(f"{key}: invalid type {entry.get('type')!r}")
|
|
1559
|
-
lifecycle = entry.get("lifecycle", "unknown")
|
|
1560
|
-
if lifecycle not in LIFECYCLES:
|
|
1561
|
-
errors.append(f"{key}: invalid lifecycle {lifecycle!r}")
|
|
1562
|
-
if entry.get("confidence", "unknown") not in CONFIDENCES:
|
|
1563
|
-
errors.append(f"{key}: invalid confidence {entry.get('confidence')!r}")
|
|
1564
|
-
indexed = entry.get("indexed")
|
|
1565
|
-
if not isinstance(indexed, dict):
|
|
1566
|
-
errors.append(f"{key}: indexed must be an object")
|
|
1567
|
-
else:
|
|
1568
|
-
indexed_hash = indexed.get("sourceHash")
|
|
1569
|
-
if not isinstance(indexed_hash, str) or not SHA256_RE.fullmatch(indexed_hash):
|
|
1570
|
-
errors.append(f"{key}: indexed.sourceHash must be a sha256 hash")
|
|
1571
|
-
unresolved = entry.get("unresolvedReferences", [])
|
|
1572
|
-
if not isinstance(unresolved, list) or any(not isinstance(item, str) for item in unresolved):
|
|
1573
|
-
errors.append(f"{key}: unresolvedReferences must be an array of strings")
|
|
1574
|
-
relations = entry.get("relations", {})
|
|
1575
|
-
if not isinstance(relations, dict):
|
|
1576
|
-
errors.append(f"{key}: relations must be an object")
|
|
1577
|
-
else:
|
|
1578
|
-
for kind in ("code", "specs"):
|
|
1579
|
-
values = relations.get(kind, [])
|
|
1580
|
-
if not isinstance(values, list):
|
|
1581
|
-
errors.append(f"{key}: relations.{kind} must be an array")
|
|
1582
|
-
continue
|
|
1583
|
-
for relation in values:
|
|
1584
|
-
if not isinstance(relation, dict) or relation.get("source") not in {"explicit", "inferred"} or not isinstance(relation.get("path"), str):
|
|
1585
|
-
errors.append(f"{key}: malformed relations.{kind} item")
|
|
1586
|
-
continue
|
|
1587
|
-
if kind == "specs" and relation.get("kind", "related") not in SPEC_RELATION_KINDS:
|
|
1588
|
-
errors.append(f"{key}: invalid spec relation kind {relation.get('kind')!r}")
|
|
1589
|
-
try:
|
|
1590
|
-
normalized_relation = normalize_rel_path(root, relation["path"])
|
|
1591
|
-
if normalized_relation != relation["path"]:
|
|
1592
|
-
errors.append(f"{key}: relation path is not normalized: {relation['path']}")
|
|
1593
|
-
except ValueError as exc:
|
|
1594
|
-
errors.append(f"{key}: invalid relation {relation['path']}: {exc}")
|
|
1595
|
-
verified = entry.get("verified")
|
|
1596
|
-
if verified is not None:
|
|
1597
|
-
if not isinstance(verified, dict):
|
|
1598
|
-
errors.append(f"{key}: verified must be an object")
|
|
1599
|
-
else:
|
|
1600
|
-
source_hash = verified.get("sourceHash")
|
|
1601
|
-
if not isinstance(source_hash, str) or not SHA256_RE.fullmatch(source_hash):
|
|
1602
|
-
errors.append(f"{key}: verified.sourceHash must be a sha256 hash")
|
|
1603
|
-
code_hashes = verified.get("codeHashes")
|
|
1604
|
-
if not isinstance(code_hashes, dict):
|
|
1605
|
-
errors.append(f"{key}: verified.codeHashes must be an object")
|
|
1606
|
-
else:
|
|
1607
|
-
for relation_path, value in code_hashes.items():
|
|
1608
|
-
if not isinstance(relation_path, str) or not isinstance(value, str) or not SHA256_RE.fullmatch(value):
|
|
1609
|
-
errors.append(f"{key}: malformed verified.codeHashes entry")
|
|
1610
|
-
verified_relations_hash = verified.get("relationsHash")
|
|
1611
|
-
if verified_relations_hash is not None and (
|
|
1612
|
-
not isinstance(verified_relations_hash, str)
|
|
1613
|
-
or not SHA256_RE.fullmatch(verified_relations_hash)
|
|
1614
|
-
):
|
|
1615
|
-
errors.append(f"{key}: verified.relationsHash must be a sha256 hash when present")
|
|
1616
|
-
if classification in SPEC_CLASSIFICATIONS:
|
|
1617
|
-
status = current_entry_status(root, entry)
|
|
1618
|
-
if lifecycle not in {"historical", "superseded"} and status["status"] != "fresh":
|
|
1619
|
-
warnings.append(f"{key}: {status['status']}")
|
|
1620
|
-
code_values = relations.get("code", []) if isinstance(relations, dict) else []
|
|
1621
|
-
if entry.get("type") == "as-is" and lifecycle == "active" and not code_values:
|
|
1622
|
-
warnings.append(f"{key}: active as-is spec has no tracked implementation inputs")
|
|
1623
|
-
if unresolved:
|
|
1624
|
-
warnings.append(f"{key}: {len(unresolved)} unresolved path reference(s)")
|
|
1625
|
-
if lifecycle == "superseded":
|
|
1626
|
-
spec_values = relations.get("specs", []) if isinstance(relations, dict) else []
|
|
1627
|
-
if not any(isinstance(item, dict) and item.get("kind") == "superseded-by" for item in spec_values):
|
|
1628
|
-
warnings.append(f"{key}: superseded lifecycle has no superseded-by relation")
|
|
1629
|
-
return errors, warnings
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
def path_overlaps(changed: str, tracked: str) -> bool:
|
|
1633
|
-
changed = changed.rstrip("/")
|
|
1634
|
-
tracked = tracked.rstrip("/")
|
|
1635
|
-
return changed == tracked or tracked.startswith(changed + "/") or changed.startswith(tracked + "/")
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
def affected_specs(root: Path, state: dict[str, Any], changed_values: list[str]) -> list[dict[str, Any]]:
|
|
1639
|
-
changed = [normalize_rel_path(root, value) for value in changed_values]
|
|
1640
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
1641
|
-
result: list[dict[str, Any]] = []
|
|
1642
|
-
for entry in entries.values():
|
|
1643
|
-
if not isinstance(entry, dict) or entry.get("classification") not in SPEC_CLASSIFICATIONS:
|
|
1644
|
-
continue
|
|
1645
|
-
if entry.get("lifecycle", "unknown") in {"historical", "superseded"}:
|
|
1646
|
-
continue
|
|
1647
|
-
tracked = [str(entry.get("path", ""))]
|
|
1648
|
-
relations = entry.get("relations") if isinstance(entry.get("relations"), dict) else {}
|
|
1649
|
-
tracked.extend(
|
|
1650
|
-
item["path"]
|
|
1651
|
-
for item in relations.get("code", [])
|
|
1652
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1653
|
-
)
|
|
1654
|
-
matches = sorted({path for path in changed if any(path_overlaps(path, candidate) for candidate in tracked)})
|
|
1655
|
-
if matches:
|
|
1656
|
-
result.append({"path": entry.get("path"), "matchedChanges": matches, **current_entry_status(root, entry)})
|
|
1657
|
-
return sorted(result, key=lambda item: str(item.get("path", "")))
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
def changed_document_candidate(
|
|
1661
|
-
root: Path,
|
|
1662
|
-
state_dir: Path,
|
|
1663
|
-
config: dict[str, Any],
|
|
1664
|
-
state: dict[str, Any],
|
|
1665
|
-
rel: str,
|
|
1666
|
-
) -> dict[str, Any] | None:
|
|
1667
|
-
try:
|
|
1668
|
-
rel = normalize_rel_path(root, rel)
|
|
1669
|
-
except ValueError:
|
|
1670
|
-
return None
|
|
1671
|
-
if is_under_ignored_dir(rel, state_dir, root):
|
|
1672
|
-
return None
|
|
1673
|
-
ignored = [str(item) for item in config.get("ignoreSpecs", [])]
|
|
1674
|
-
excludes = [str(item) for item in config.get("exclude", [])]
|
|
1675
|
-
forced_patterns = [str(item) for item in config.get("forceSpecs", [])]
|
|
1676
|
-
if matches_any(rel, ignored) or matches_any(rel, excludes):
|
|
1677
|
-
return None
|
|
1678
|
-
forced = matches_any(rel, forced_patterns)
|
|
1679
|
-
extensions = {
|
|
1680
|
-
str(item).lower() if str(item).startswith(".") else f".{str(item).lower()}"
|
|
1681
|
-
for item in config.get("includeExtensions", [])
|
|
1682
|
-
}
|
|
1683
|
-
if not forced and extensions and Path(rel).suffix.lower() not in extensions:
|
|
1684
|
-
return None
|
|
1685
|
-
path = root / rel
|
|
1686
|
-
if not path.is_file() or path.is_symlink():
|
|
1687
|
-
return None
|
|
1688
|
-
loaded = read_candidate(path, int(config.get("maxFileBytes", DEFAULT_CONFIG["maxFileBytes"])))
|
|
1689
|
-
if not loaded:
|
|
1690
|
-
return None
|
|
1691
|
-
text, data = loaded
|
|
1692
|
-
score, role_hint, signals = discovery_signals(rel, text, forced)
|
|
1693
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
1694
|
-
known = entries.get(rel) if isinstance(entries.get(rel), dict) else None
|
|
1695
|
-
return {
|
|
1696
|
-
"path": rel,
|
|
1697
|
-
"title": document_title(text, rel),
|
|
1698
|
-
"score": score,
|
|
1699
|
-
"roleHint": role_hint,
|
|
1700
|
-
"signals": signals,
|
|
1701
|
-
"currentHash": sha256_bytes(data),
|
|
1702
|
-
"knownClassification": known.get("classification") if known else None,
|
|
1703
|
-
"knownLifecycle": known.get("lifecycle") if known else None,
|
|
1704
|
-
"requiresClassification": known is None,
|
|
1705
|
-
"requiresReclassification": bool(
|
|
1706
|
-
known and known.get("classification") not in SPEC_CLASSIFICATIONS
|
|
1707
|
-
),
|
|
1708
|
-
"changedKnownPrimary": bool(known and known.get("classification") in SPEC_CLASSIFICATIONS),
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
def impact_payload(
|
|
1713
|
-
root: Path,
|
|
1714
|
-
state_dir: Path,
|
|
1715
|
-
config: dict[str, Any],
|
|
1716
|
-
state: dict[str, Any],
|
|
1717
|
-
explicit_paths: list[str] | None,
|
|
1718
|
-
base: str,
|
|
1719
|
-
) -> dict[str, Any]:
|
|
1720
|
-
if explicit_paths:
|
|
1721
|
-
records = [{"status": "explicit", "path": normalize_rel_path(root, value)} for value in explicit_paths]
|
|
1722
|
-
source = "explicit"
|
|
1723
|
-
else:
|
|
1724
|
-
records = filter_internal_change_records(git_changed_records(root, base), state_dir, root)
|
|
1725
|
-
source = f"git:{base}"
|
|
1726
|
-
changed_paths = changed_record_paths(records)
|
|
1727
|
-
known_affected = affected_specs(root, state, changed_paths) if changed_paths else []
|
|
1728
|
-
|
|
1729
|
-
coverage: dict[str, list[str]] = {path: [] for path in changed_paths}
|
|
1730
|
-
for item in known_affected:
|
|
1731
|
-
spec_path = item.get("path")
|
|
1732
|
-
if not isinstance(spec_path, str):
|
|
1733
|
-
continue
|
|
1734
|
-
for path in item.get("matchedChanges", []):
|
|
1735
|
-
if isinstance(path, str) and path in coverage:
|
|
1736
|
-
coverage[path].append(spec_path)
|
|
1737
|
-
coverage_rows = [
|
|
1738
|
-
{"path": path, "knownSpecs": sorted(set(coverage.get(path, [])))}
|
|
1739
|
-
for path in changed_paths
|
|
1740
|
-
]
|
|
1741
|
-
|
|
1742
|
-
changed_documents: list[dict[str, Any]] = []
|
|
1743
|
-
document_paths: set[str] = set()
|
|
1744
|
-
for record in records:
|
|
1745
|
-
path = record.get("path")
|
|
1746
|
-
if not isinstance(path, str):
|
|
1747
|
-
continue
|
|
1748
|
-
candidate = changed_document_candidate(root, state_dir, config, state, path)
|
|
1749
|
-
if candidate:
|
|
1750
|
-
candidate["changeStatus"] = record.get("status")
|
|
1751
|
-
changed_documents.append(candidate)
|
|
1752
|
-
document_paths.add(candidate["path"])
|
|
1753
|
-
|
|
1754
|
-
entries = state.get("entries", {}) if isinstance(state.get("entries"), dict) else {}
|
|
1755
|
-
missing_tracked_specs: list[str] = []
|
|
1756
|
-
for path in changed_paths:
|
|
1757
|
-
entry = entries.get(path) if isinstance(entries.get(path), dict) else None
|
|
1758
|
-
if entry and entry.get("classification") in SPEC_CLASSIFICATIONS and not (root / path).is_file():
|
|
1759
|
-
missing_tracked_specs.append(path)
|
|
1760
|
-
|
|
1761
|
-
uncovered_paths = [
|
|
1762
|
-
path for path in changed_paths
|
|
1763
|
-
if not coverage.get(path)
|
|
1764
|
-
and path not in document_paths
|
|
1765
|
-
and not is_under_ignored_dir(path, state_dir, root)
|
|
1766
|
-
]
|
|
1767
|
-
changed_docs_requiring_classification = [
|
|
1768
|
-
item["path"]
|
|
1769
|
-
for item in changed_documents
|
|
1770
|
-
if item.get("requiresClassification") or item.get("requiresReclassification")
|
|
1771
|
-
]
|
|
1772
|
-
|
|
1773
|
-
reasons: list[str] = []
|
|
1774
|
-
if known_affected:
|
|
1775
|
-
reasons.append("known-relationships-affected")
|
|
1776
|
-
if uncovered_paths:
|
|
1777
|
-
reasons.append("changed-paths-without-known-spec-relations")
|
|
1778
|
-
if changed_docs_requiring_classification:
|
|
1779
|
-
reasons.append("changed-or-new-documents-require-classification")
|
|
1780
|
-
if missing_tracked_specs:
|
|
1781
|
-
reasons.append("tracked-primary-source-missing-or-moved")
|
|
1782
|
-
return {
|
|
1783
|
-
"schemaVersion": SCHEMA_VERSION,
|
|
1784
|
-
"source": source,
|
|
1785
|
-
"changes": records,
|
|
1786
|
-
"changedPaths": changed_paths,
|
|
1787
|
-
"coverage": coverage_rows,
|
|
1788
|
-
"knownAffected": known_affected,
|
|
1789
|
-
"uncoveredPaths": uncovered_paths,
|
|
1790
|
-
"changedDocuments": sorted(changed_documents, key=lambda item: str(item.get("path", ""))),
|
|
1791
|
-
"missingTrackedSpecs": sorted(missing_tracked_specs),
|
|
1792
|
-
"semanticSweepRequired": bool(
|
|
1793
|
-
known_affected
|
|
1794
|
-
or uncovered_paths
|
|
1795
|
-
or changed_docs_requiring_classification
|
|
1796
|
-
or missing_tracked_specs
|
|
1797
|
-
),
|
|
1798
|
-
"reasons": reasons,
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
def parser() -> argparse.ArgumentParser:
|
|
1803
|
-
main = argparse.ArgumentParser(description=__doc__)
|
|
1804
|
-
main.add_argument("--root", help="Project root. Defaults to Git root, then cwd.")
|
|
1805
|
-
main.add_argument("--state-dir", help="Wiki state directory. Defaults to .spec-wiki under root.")
|
|
1806
|
-
main.add_argument("--config", help="Optional config.jsonc/json path.")
|
|
1807
|
-
sub = main.add_subparsers(dest="command", required=True)
|
|
1808
|
-
|
|
1809
|
-
discover = sub.add_parser("discover", help="Find a bounded heuristic shortlist for semantic classification.")
|
|
1810
|
-
discover.add_argument("--limit", type=int, default=40)
|
|
1811
|
-
discover.add_argument("--offset", type=int, default=0)
|
|
1812
|
-
discover.add_argument("--all", action="store_true", help="Include unchanged previously classified candidates.")
|
|
1813
|
-
discover.add_argument(
|
|
1814
|
-
"--all-unclassified",
|
|
1815
|
-
action="store_true",
|
|
1816
|
-
help="Include every unclassified document-like file regardless heuristic score; use for bounded periodic coverage audits.",
|
|
1817
|
-
)
|
|
1818
|
-
discover.add_argument("--json", action="store_true")
|
|
1819
|
-
|
|
1820
|
-
record = sub.add_parser("record", help="Record one semantic classification/index snapshot without claiming semantic verification.")
|
|
1821
|
-
record.add_argument("--path", required=True)
|
|
1822
|
-
record.add_argument("--classification", required=True, choices=sorted(CLASSIFICATIONS))
|
|
1823
|
-
record.add_argument("--type", choices=sorted(SPEC_TYPES))
|
|
1824
|
-
record.add_argument("--lifecycle", choices=sorted(LIFECYCLES))
|
|
1825
|
-
record.add_argument("--confidence", choices=sorted(CONFIDENCES))
|
|
1826
|
-
record.add_argument("--summary")
|
|
1827
|
-
record.add_argument("--topic", action="append", default=None)
|
|
1828
|
-
record.add_argument("--code", action="append", default=None, help="Inferred implementation/test relationship; repeatable.")
|
|
1829
|
-
record.add_argument("--related-spec", action="append", default=None, help="Inferred related spec/doc relationship; repeatable.")
|
|
1830
|
-
record.add_argument("--supersedes", action="append", default=None, help="Inferred spec/doc superseded by this entry; repeatable.")
|
|
1831
|
-
record.add_argument("--superseded-by", action="append", default=None, help="Inferred newer spec/doc that supersedes this entry; repeatable.")
|
|
1832
|
-
record.add_argument("--clear-code", action="store_true", help="Clear all previously inferred code relationships before recording.")
|
|
1833
|
-
record.add_argument("--clear-related-specs", action="store_true", help="Clear all previously inferred spec/doc relationships before recording.")
|
|
1834
|
-
|
|
1835
|
-
verify = sub.add_parser("verify", help="Establish a semantic-verification baseline for one already-recorded primary spec.")
|
|
1836
|
-
verify.add_argument("--path", required=True)
|
|
1837
|
-
|
|
1838
|
-
relate = sub.add_parser("relate", help="Add/remove inferred spec relationships without rewriting classification metadata.")
|
|
1839
|
-
relate.add_argument("--path", required=True, help="Primary spec whose relation map should change.")
|
|
1840
|
-
relate.add_argument("--add-code", action="append", default=[])
|
|
1841
|
-
relate.add_argument("--remove-code", action="append", default=[])
|
|
1842
|
-
relate.add_argument("--add-related-spec", action="append", default=[])
|
|
1843
|
-
relate.add_argument("--remove-related-spec", action="append", default=[])
|
|
1844
|
-
relate.add_argument("--add-supersedes", action="append", default=[])
|
|
1845
|
-
relate.add_argument("--remove-supersedes", action="append", default=[])
|
|
1846
|
-
relate.add_argument("--add-superseded-by", action="append", default=[])
|
|
1847
|
-
relate.add_argument("--remove-superseded-by", action="append", default=[])
|
|
1848
|
-
|
|
1849
|
-
remove = sub.add_parser("remove", help="Remove one Spec Wiki metadata entry without deleting its source.")
|
|
1850
|
-
remove.add_argument("--path", required=True)
|
|
1851
|
-
|
|
1852
|
-
for name in ("audit", "status"):
|
|
1853
|
-
audit = sub.add_parser(name, help="Report primary-spec freshness and new/changed candidates.")
|
|
1854
|
-
audit.add_argument("--json", action="store_true")
|
|
1855
|
-
audit.add_argument("--candidate-limit", type=int, default=20)
|
|
1856
|
-
|
|
1857
|
-
affected = sub.add_parser("affected", help="Find known specs related to changed repository paths.")
|
|
1858
|
-
affected.add_argument("paths", nargs="+")
|
|
1859
|
-
affected.add_argument("--json", action="store_true")
|
|
1860
|
-
|
|
1861
|
-
changes = sub.add_parser("changes", help="List current Git working-tree changes including untracked files and renames.")
|
|
1862
|
-
changes.add_argument("--base", default="HEAD")
|
|
1863
|
-
changes.add_argument("--json", action="store_true")
|
|
1864
|
-
|
|
1865
|
-
impact = sub.add_parser(
|
|
1866
|
-
"impact",
|
|
1867
|
-
help="Build a deterministic post-change maintenance report: known affected specs, uncovered paths, changed docs, moves/deletions.",
|
|
1868
|
-
)
|
|
1869
|
-
impact.add_argument("paths", nargs="*", help="Task-scoped changed paths. If omitted, inspect Git changes against --base.")
|
|
1870
|
-
impact.add_argument("--base", default="HEAD")
|
|
1871
|
-
impact.add_argument("--json", action="store_true")
|
|
1872
|
-
|
|
1873
|
-
search = sub.add_parser("search", help="Search indexed spec knowledge without loading the full generated index.")
|
|
1874
|
-
search.add_argument("query")
|
|
1875
|
-
search.add_argument("--also", action="append", default=[], help="Additional normalized/expanded query; repeatable. Scores are merged deterministically.")
|
|
1876
|
-
search.add_argument("--limit", type=int, default=8)
|
|
1877
|
-
search.add_argument("--include-secondary", action="store_true", help="Include design-only secondary references below primary specs.")
|
|
1878
|
-
search.add_argument("--json", action="store_true")
|
|
1879
|
-
|
|
1880
|
-
sub.add_parser("render", help="Regenerate the compact LLM-facing index.md.")
|
|
1881
|
-
sub.add_parser("validate", help="Validate state schema and relationship safety.")
|
|
1882
|
-
return main
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
def main(argv: list[str] | None = None) -> int:
|
|
1886
|
-
args = parser().parse_args(argv)
|
|
1887
|
-
try:
|
|
1888
|
-
root = detect_root(args.root)
|
|
1889
|
-
if not root.is_dir():
|
|
1890
|
-
raise ValueError(f"project root is not a directory: {root}")
|
|
1891
|
-
state_dir = resolve_state_dir(root, args.state_dir)
|
|
1892
|
-
config = load_config(root, state_dir, args.config)
|
|
1893
|
-
state = load_state(state_dir)
|
|
1894
|
-
if args.command != "validate":
|
|
1895
|
-
errors, _warnings = validate_state(root, state)
|
|
1896
|
-
if errors:
|
|
1897
|
-
raise ValueError("invalid state: " + "; ".join(errors[:5]))
|
|
1898
|
-
|
|
1899
|
-
if args.command == "discover":
|
|
1900
|
-
if args.all and args.all_unclassified:
|
|
1901
|
-
raise ValueError("--all and --all-unclassified are mutually exclusive")
|
|
1902
|
-
candidates = discover_candidates(
|
|
1903
|
-
root,
|
|
1904
|
-
state_dir,
|
|
1905
|
-
config,
|
|
1906
|
-
state,
|
|
1907
|
-
include_all=args.all,
|
|
1908
|
-
all_unclassified=args.all_unclassified,
|
|
1909
|
-
)
|
|
1910
|
-
offset = max(0, args.offset)
|
|
1911
|
-
limit = max(1, args.limit)
|
|
1912
|
-
page = candidates[offset : offset + limit]
|
|
1913
|
-
payload = {
|
|
1914
|
-
"total": len(candidates),
|
|
1915
|
-
"offset": offset,
|
|
1916
|
-
"limit": limit,
|
|
1917
|
-
"hasMore": offset + len(page) < len(candidates),
|
|
1918
|
-
"candidates": page,
|
|
1919
|
-
}
|
|
1920
|
-
if args.json:
|
|
1921
|
-
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1922
|
-
else:
|
|
1923
|
-
print(f"candidates: {len(candidates)} | showing {offset + 1 if page else 0}-{offset + len(page)}")
|
|
1924
|
-
for item in page:
|
|
1925
|
-
known = f" known={item['knownClassification']}" if item.get("knownClassification") else ""
|
|
1926
|
-
print(f"score={item['score']:>3} {item['roleHint']:14} {item['path']}{known}")
|
|
1927
|
-
print(f" title: {item['title']}")
|
|
1928
|
-
print(f" signals: {', '.join(item['signals']) or 'none'}")
|
|
1929
|
-
if payload["hasMore"]:
|
|
1930
|
-
print(
|
|
1931
|
-
f"more: use --offset {offset + len(page)} only if state stays unchanged; "
|
|
1932
|
-
"after recording this page rerun from --offset 0"
|
|
1933
|
-
)
|
|
1934
|
-
return 0
|
|
1935
|
-
|
|
1936
|
-
if args.command == "record":
|
|
1937
|
-
return record_entry(args, root, state_dir, state)
|
|
1938
|
-
|
|
1939
|
-
if args.command == "verify":
|
|
1940
|
-
return verify_entry(args.path, root, state_dir, state)
|
|
1941
|
-
|
|
1942
|
-
if args.command == "relate":
|
|
1943
|
-
return relate_entry(args, root, state_dir, state)
|
|
1944
|
-
|
|
1945
|
-
if args.command == "remove":
|
|
1946
|
-
rel = normalize_rel_path(root, args.path)
|
|
1947
|
-
entries = state.get("entries")
|
|
1948
|
-
if not isinstance(entries, dict) or rel not in entries:
|
|
1949
|
-
raise ValueError(f"state entry does not exist: {rel}")
|
|
1950
|
-
del entries[rel]
|
|
1951
|
-
save_state(state_dir, state)
|
|
1952
|
-
print(f"removed state entry: {rel}")
|
|
1953
|
-
return 0
|
|
1954
|
-
|
|
1955
|
-
if args.command in {"audit", "status"}:
|
|
1956
|
-
payload = audit_payload(root, state_dir, config, state, max(1, args.candidate_limit))
|
|
1957
|
-
if args.json:
|
|
1958
|
-
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1959
|
-
else:
|
|
1960
|
-
print_audit(payload)
|
|
1961
|
-
return 0
|
|
1962
|
-
|
|
1963
|
-
if args.command == "affected":
|
|
1964
|
-
result = affected_specs(root, state, args.paths)
|
|
1965
|
-
if args.json:
|
|
1966
|
-
print(json.dumps({"affected": result}, indent=2, ensure_ascii=False))
|
|
1967
|
-
else:
|
|
1968
|
-
if not result:
|
|
1969
|
-
print("no tracked spec relationships matched")
|
|
1970
|
-
for item in result:
|
|
1971
|
-
print(f"{item['path']} — {item['status']} — {', '.join(item['matchedChanges'])}")
|
|
1972
|
-
return 0
|
|
1973
|
-
|
|
1974
|
-
if args.command == "changes":
|
|
1975
|
-
records = filter_internal_change_records(git_changed_records(root, args.base), state_dir, root)
|
|
1976
|
-
payload = {"base": args.base, "changes": records, "paths": changed_record_paths(records)}
|
|
1977
|
-
if args.json:
|
|
1978
|
-
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1979
|
-
else:
|
|
1980
|
-
if not records:
|
|
1981
|
-
print("no Git working-tree changes")
|
|
1982
|
-
for record in records:
|
|
1983
|
-
if record.get("oldPath"):
|
|
1984
|
-
print(f"{record['status']:>2} {record['oldPath']} -> {record['path']}")
|
|
1985
|
-
else:
|
|
1986
|
-
print(f"{record['status']:>2} {record['path']}")
|
|
1987
|
-
return 0
|
|
1988
|
-
|
|
1989
|
-
if args.command == "impact":
|
|
1990
|
-
payload = impact_payload(root, state_dir, config, state, args.paths or None, args.base)
|
|
1991
|
-
if args.json:
|
|
1992
|
-
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1993
|
-
else:
|
|
1994
|
-
print(
|
|
1995
|
-
f"changed: {len(payload['changedPaths'])} | known affected specs: {len(payload['knownAffected'])} | "
|
|
1996
|
-
f"uncovered paths: {len(payload['uncoveredPaths'])} | changed docs: {len(payload['changedDocuments'])} | "
|
|
1997
|
-
f"semantic sweep: {'yes' if payload['semanticSweepRequired'] else 'no'}"
|
|
1998
|
-
)
|
|
1999
|
-
for item in payload["knownAffected"]:
|
|
2000
|
-
print(f" known {item['path']} — {item['status']} — {', '.join(item['matchedChanges'])}")
|
|
2001
|
-
for path in payload["uncoveredPaths"]:
|
|
2002
|
-
print(f" uncovered {path}")
|
|
2003
|
-
for item in payload["changedDocuments"]:
|
|
2004
|
-
classification = item.get("knownClassification") or "unclassified"
|
|
2005
|
-
print(f" doc {item['path']} — {classification} — score={item['score']} {item['roleHint']}")
|
|
2006
|
-
for path in payload["missingTrackedSpecs"]:
|
|
2007
|
-
print(f" missing-spec {path}")
|
|
2008
|
-
return 0
|
|
2009
|
-
|
|
2010
|
-
if args.command == "search":
|
|
2011
|
-
queries = [args.query, *args.also]
|
|
2012
|
-
result = search_entries(root, state, queries, args.limit, args.include_secondary)
|
|
2013
|
-
quality = search_quality(result)
|
|
2014
|
-
if args.json:
|
|
2015
|
-
print(
|
|
2016
|
-
json.dumps(
|
|
2017
|
-
{
|
|
2018
|
-
"query": args.query,
|
|
2019
|
-
"queries": [query for query in queries if normalize_search_text(query)],
|
|
2020
|
-
**quality,
|
|
2021
|
-
"results": result,
|
|
2022
|
-
},
|
|
2023
|
-
indent=2,
|
|
2024
|
-
ensure_ascii=False,
|
|
2025
|
-
)
|
|
2026
|
-
)
|
|
2027
|
-
else:
|
|
2028
|
-
print(
|
|
2029
|
-
f"confidence={quality['confidence']} "
|
|
2030
|
-
f"catalogFallback={'yes' if quality['catalogFallbackRecommended'] else 'no'} "
|
|
2031
|
-
f"semanticRerank={'yes' if quality['semanticRerankRecommended'] else 'no'}"
|
|
2032
|
-
)
|
|
2033
|
-
if not result:
|
|
2034
|
-
print("no indexed spec knowledge matched")
|
|
2035
|
-
for item in result:
|
|
2036
|
-
topics = ",".join(item.get("topics", [])[:5]) or "-"
|
|
2037
|
-
print(
|
|
2038
|
-
f"score={item['score']:>5} {item['lifecycle']:10} {item['status']:18} "
|
|
2039
|
-
f"{item['path']} — {item['title']} [{topics}]"
|
|
2040
|
-
)
|
|
2041
|
-
print(f" {compact_text(item.get('summary', ''), 260)}")
|
|
2042
|
-
matched_queries = [match["query"] for match in item.get("queryMatches", [])[:3]]
|
|
2043
|
-
if matched_queries:
|
|
2044
|
-
print(f" matched: {' | '.join(matched_queries)}")
|
|
2045
|
-
return 0
|
|
2046
|
-
|
|
2047
|
-
if args.command == "render":
|
|
2048
|
-
rendered = render_index(root, state_dir, state)
|
|
2049
|
-
target = index_file(state_dir)
|
|
2050
|
-
previous = target.read_text(encoding="utf-8") if target.exists() else None
|
|
2051
|
-
if previous != rendered:
|
|
2052
|
-
atomic_write_text(target, rendered)
|
|
2053
|
-
print(f"rendered {target}")
|
|
2054
|
-
else:
|
|
2055
|
-
print(f"unchanged {target}")
|
|
2056
|
-
return 0
|
|
2057
|
-
|
|
2058
|
-
if args.command == "validate":
|
|
2059
|
-
errors, warnings = validate_state(root, state)
|
|
2060
|
-
for warning in warnings:
|
|
2061
|
-
print(f"warning: {warning}")
|
|
2062
|
-
for error in errors:
|
|
2063
|
-
print(f"error: {error}", file=sys.stderr)
|
|
2064
|
-
if errors:
|
|
2065
|
-
return 1
|
|
2066
|
-
print(f"valid state: {len(state.get('entries', {}))} entries, {len(warnings)} warnings")
|
|
2067
|
-
return 0
|
|
2068
|
-
|
|
2069
|
-
raise ValueError(f"unknown command: {args.command}")
|
|
2070
|
-
except (ValueError, OSError) as exc:
|
|
2071
|
-
print(f"spec-wiki: {exc}", file=sys.stderr)
|
|
2072
|
-
return 2
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
if __name__ == "__main__":
|
|
2076
|
-
raise SystemExit(main())
|