bmad-module-skill-forge 1.9.0 → 2.0.1
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/.claude-plugin/marketplace.json +1 -1
- package/.nvmrc +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +4 -3
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +240 -4
- package/src/shared/scripts/skf-detect-language.py +82 -3
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shape-detect.py +576 -25
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +3 -3
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +13 -18
- package/src/skf-brief-skill/references/confirm-brief.md +2 -15
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +27 -24
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +6 -15
- package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
- package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +19 -13
- package/src/skf-campaign/SKILL.md +121 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +75 -0
- package/src/skf-campaign/manifest.yaml +15 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +121 -0
- package/src/skf-campaign/references/step-02-strategy.md +102 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +88 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +115 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +244 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +15 -4
- package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +37 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +20 -27
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +10 -8
- package/src/skf-create-skill/references/generate-artifacts.md +20 -25
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +27 -14
- package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +33 -15
- package/src/skf-create-skill/references/step-doc-sources.md +13 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +69 -29
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +67 -36
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +44 -10
- package/src/skf-drop-skill/references/select.md +93 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +32 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +16 -8
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +10 -8
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +14 -11
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +112 -82
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +21 -13
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +11 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +10 -8
- package/src/skf-setup/references/report.md +26 -32
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +111 -67
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +39 -21
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +15 -5
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +135 -4
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +11 -6
- package/src/skf-update-skill/references/detect-changes.md +48 -36
- package/src/skf-update-skill/references/health-check.md +5 -11
- package/src/skf-update-skill/references/init.md +40 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +27 -25
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
- package/src/skf-rename-skill/references/rebuild-context.md +0 -110
|
@@ -16,7 +16,7 @@ Detection chain (all four methods attempted, results aggregated):
|
|
|
16
16
|
4. docs_folder — markdown files in the repo's docs/ directory
|
|
17
17
|
|
|
18
18
|
CLI:
|
|
19
|
-
uv run
|
|
19
|
+
uv run src/shared/scripts/skf-detect-docs.py \\
|
|
20
20
|
--repo-url <url> [--local-path <path>] [--skip-pages-api]
|
|
21
21
|
|
|
22
22
|
Input:
|
|
@@ -38,6 +38,42 @@ Exit codes:
|
|
|
38
38
|
0 found >=1 documentation source
|
|
39
39
|
1 no documentation sources found (empty array)
|
|
40
40
|
2 error (invalid args, gh not found, etc.)
|
|
41
|
+
|
|
42
|
+
---------------------------------------------------------------------------
|
|
43
|
+
Subcommand: compare-hashes
|
|
44
|
+
---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
Doc-drift detection for audit-skill. Given a set of tracked doc sources
|
|
47
|
+
(each `{url, content_hash}`), re-fetch every URL, hash the response bytes with
|
|
48
|
+
the SAME fetch+sha256 primitive the compile side used, and classify each entry
|
|
49
|
+
as changed / unchanged / fetch_failed / skipped_null_hash. This replaces the
|
|
50
|
+
per-URL fetch/hash/compare/count prose at
|
|
51
|
+
`src/skf-audit-skill/references/step-doc-drift.md` §2-3 — the model no longer
|
|
52
|
+
orchestrates HTTP GETs or computes sha256 by hand, and correctness no longer
|
|
53
|
+
depends on whether a WebFetch tool is wired.
|
|
54
|
+
|
|
55
|
+
uv run src/shared/scripts/skf-detect-docs.py compare-hashes <source>
|
|
56
|
+
|
|
57
|
+
Input `<source>`:
|
|
58
|
+
- a path to a JSON file that is EITHER an array of `{url, content_hash}`
|
|
59
|
+
entries OR an object with a `doc_sources` array (e.g. a skill's
|
|
60
|
+
metadata.json), OR
|
|
61
|
+
- `-` to read that JSON from stdin.
|
|
62
|
+
|
|
63
|
+
Output (JSON object on stdout):
|
|
64
|
+
{
|
|
65
|
+
"changed": [{"url","old_hash","new_hash"}],
|
|
66
|
+
"unchanged": [{"url"}],
|
|
67
|
+
"fetch_failed": [{"url","old_hash","reason"}],
|
|
68
|
+
"skipped_null_hash": [{"url"}],
|
|
69
|
+
"stats": {"total_tracked","changed","unchanged",
|
|
70
|
+
"fetch_failed","skipped_null_hash"}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Each list is stably sorted by url. Stored hashes are normalized (leading
|
|
74
|
+
`algo:` prefix stripped) before comparison so a bare-hex writer form still
|
|
75
|
+
matches. Exit 0 on any well-formed input (never blocks the informational
|
|
76
|
+
audit); exit 2 on malformed args or JSON.
|
|
41
77
|
"""
|
|
42
78
|
|
|
43
79
|
from __future__ import annotations
|
|
@@ -47,12 +83,13 @@ import base64
|
|
|
47
83
|
import hashlib
|
|
48
84
|
import json
|
|
49
85
|
import re
|
|
86
|
+
import socket
|
|
50
87
|
import subprocess
|
|
51
88
|
import sys
|
|
52
89
|
import urllib.error
|
|
53
90
|
import urllib.request
|
|
54
91
|
from pathlib import Path
|
|
55
|
-
from typing import Any, Dict, List, Optional
|
|
92
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
56
93
|
|
|
57
94
|
_GITHUB_URL_RE = re.compile(
|
|
58
95
|
r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
|
|
@@ -86,13 +123,18 @@ _MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
|
|
86
123
|
_HTML_LINK_RE = re.compile(r'<a\s[^>]*href=["\']([^"\']+)["\']', re.IGNORECASE)
|
|
87
124
|
_BARE_URL_RE = re.compile(r"^(https?://\S+)$", re.MULTILINE)
|
|
88
125
|
|
|
126
|
+
# `docs?\.` (not `docs\.`) so a `doc.` subdomain matches too — language doc
|
|
127
|
+
# sites use the singular form (doc.rust-lang.org, doc.qt.io).
|
|
89
128
|
_DOC_DOMAIN_RE = re.compile(
|
|
90
|
-
r"(?:docs
|
|
129
|
+
r"(?:docs?\.|\.readthedocs\.|wiki\.|documentation\.)",
|
|
91
130
|
re.IGNORECASE,
|
|
92
131
|
)
|
|
93
132
|
|
|
133
|
+
# Language reference/guide path segments (a Book, a std/library API, a tutorial)
|
|
134
|
+
# are doc URLs even on a bare domain (doc.rust-lang.org/book/, .../std/).
|
|
94
135
|
_DOC_PATH_RE = re.compile(
|
|
95
|
-
r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/
|
|
136
|
+
r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/"
|
|
137
|
+
r"|/book/|/std/|/library/|/tutorial/)",
|
|
96
138
|
re.IGNORECASE,
|
|
97
139
|
)
|
|
98
140
|
|
|
@@ -219,6 +261,57 @@ def _fetch_and_hash(url: str) -> Optional[str]:
|
|
|
219
261
|
return None
|
|
220
262
|
|
|
221
263
|
|
|
264
|
+
# Leading algorithm-name prefix on a stored hash (`sha256:`, `sha1:`, ...) so a
|
|
265
|
+
# bare-hex writer form and the prefixed form compare equal.
|
|
266
|
+
_HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _normalize_hash(value: Optional[str]) -> Optional[str]:
|
|
270
|
+
"""Strip a leading `algo:` prefix from a hash so bare-hex and prefixed
|
|
271
|
+
forms compare equal. Returns None for non-string input. Idempotent."""
|
|
272
|
+
if not isinstance(value, str):
|
|
273
|
+
return None
|
|
274
|
+
return _HASH_PREFIX_RE.sub("", value, count=1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
278
|
+
"""Byte-symmetric sibling of `_fetch_and_hash` that surfaces WHY a fetch
|
|
279
|
+
failed for drift reporting.
|
|
280
|
+
|
|
281
|
+
Uses the identical fetch+sha256 primitive as the compile side — same
|
|
282
|
+
`USER_AGENT`, same `_FETCH_TIMEOUT`, sha256 of the raw response bytes with
|
|
283
|
+
the `sha256:` prefix — so a hash produced here compares byte-for-byte
|
|
284
|
+
against a `content_hash` written by `_fetch_and_hash` at compile time.
|
|
285
|
+
|
|
286
|
+
Returns `(content_hash, None)` on success or `(None, reason)` on failure.
|
|
287
|
+
Never raises: the doc-drift audit must never abort on a bad URL.
|
|
288
|
+
"""
|
|
289
|
+
if url.startswith("file://"):
|
|
290
|
+
local_path = url[7:]
|
|
291
|
+
try:
|
|
292
|
+
with open(local_path, "rb") as fh:
|
|
293
|
+
content = fh.read()
|
|
294
|
+
except OSError as exc:
|
|
295
|
+
return None, f"local read failed: {exc}"
|
|
296
|
+
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
297
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
298
|
+
try:
|
|
299
|
+
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
300
|
+
content = resp.read()
|
|
301
|
+
except urllib.error.HTTPError as exc:
|
|
302
|
+
return None, f"HTTP {exc.code}"
|
|
303
|
+
except (socket.timeout, TimeoutError):
|
|
304
|
+
return None, f"timeout after {_FETCH_TIMEOUT}s"
|
|
305
|
+
except urllib.error.URLError as exc:
|
|
306
|
+
reason = getattr(exc, "reason", exc)
|
|
307
|
+
if isinstance(reason, (socket.timeout, TimeoutError)):
|
|
308
|
+
return None, f"timeout after {_FETCH_TIMEOUT}s"
|
|
309
|
+
return None, f"URL error: {reason}"
|
|
310
|
+
except Exception as exc: # never let a malformed URL crash the audit
|
|
311
|
+
return None, f"fetch failed: {exc}"
|
|
312
|
+
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
313
|
+
|
|
314
|
+
|
|
222
315
|
# ---------------------------------------------------------------------------
|
|
223
316
|
# Detection method 1 — homepageUrl
|
|
224
317
|
# ---------------------------------------------------------------------------
|
|
@@ -371,11 +464,154 @@ def detect(
|
|
|
371
464
|
return deduped
|
|
372
465
|
|
|
373
466
|
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
# compare-hashes subcommand — doc-drift detection for audit-skill
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
def _load_doc_sources(raw_text: str, source_label: str) -> List[Any]:
|
|
472
|
+
"""Parse a compare-hashes input blob into a list of doc-source entries.
|
|
473
|
+
|
|
474
|
+
Accepts either a top-level JSON array of `{url, content_hash}` entries or a
|
|
475
|
+
top-level object with a `doc_sources` array (e.g. a skill's metadata.json).
|
|
476
|
+
An object without `doc_sources` yields an empty list (nothing tracked).
|
|
477
|
+
|
|
478
|
+
Raises ValueError on malformed JSON or an unexpected top-level shape.
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
data = json.loads(raw_text)
|
|
482
|
+
except json.JSONDecodeError as exc:
|
|
483
|
+
raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
|
|
484
|
+
if isinstance(data, list):
|
|
485
|
+
return data
|
|
486
|
+
if isinstance(data, dict):
|
|
487
|
+
entries = data.get("doc_sources")
|
|
488
|
+
if entries is None:
|
|
489
|
+
return []
|
|
490
|
+
if not isinstance(entries, list):
|
|
491
|
+
raise ValueError(f"`doc_sources` in {source_label} is not an array")
|
|
492
|
+
return entries
|
|
493
|
+
raise ValueError(
|
|
494
|
+
f"{source_label} must be a JSON array or an object with a "
|
|
495
|
+
f"`doc_sources` array; got {type(data).__name__}"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def compare_doc_hashes(doc_sources: List[Any]) -> Dict[str, Any]:
|
|
500
|
+
"""Fetch/hash/compare each tracked doc source and categorize it.
|
|
501
|
+
|
|
502
|
+
Deterministic: the same entries served the same upstream bytes always
|
|
503
|
+
yield the same categorization. Every entry lands in exactly one bucket, so
|
|
504
|
+
`stats.total_tracked` == the sum of the four category counts.
|
|
505
|
+
"""
|
|
506
|
+
changed: List[Dict[str, Any]] = []
|
|
507
|
+
unchanged: List[Dict[str, Any]] = []
|
|
508
|
+
fetch_failed: List[Dict[str, Any]] = []
|
|
509
|
+
skipped_null_hash: List[Dict[str, Any]] = []
|
|
510
|
+
|
|
511
|
+
for entry in doc_sources:
|
|
512
|
+
if not isinstance(entry, dict):
|
|
513
|
+
fetch_failed.append(
|
|
514
|
+
{"url": "", "old_hash": None, "reason": "invalid entry: not an object"}
|
|
515
|
+
)
|
|
516
|
+
continue
|
|
517
|
+
url = entry.get("url")
|
|
518
|
+
if not isinstance(url, str) or not url:
|
|
519
|
+
fetch_failed.append({
|
|
520
|
+
"url": url if isinstance(url, str) else "",
|
|
521
|
+
"old_hash": entry.get("content_hash"),
|
|
522
|
+
"reason": "invalid entry: missing url",
|
|
523
|
+
})
|
|
524
|
+
continue
|
|
525
|
+
stored = entry.get("content_hash")
|
|
526
|
+
if stored is None:
|
|
527
|
+
# No baseline hash was recorded at compile time — nothing to
|
|
528
|
+
# compare against, so skip the fetch entirely.
|
|
529
|
+
skipped_null_hash.append({"url": url})
|
|
530
|
+
continue
|
|
531
|
+
new_hash, reason = _fetch_and_hash_reason(url)
|
|
532
|
+
if new_hash is None:
|
|
533
|
+
fetch_failed.append({"url": url, "old_hash": stored, "reason": reason})
|
|
534
|
+
elif _normalize_hash(new_hash) == _normalize_hash(stored):
|
|
535
|
+
unchanged.append({"url": url})
|
|
536
|
+
else:
|
|
537
|
+
changed.append({"url": url, "old_hash": stored, "new_hash": new_hash})
|
|
538
|
+
|
|
539
|
+
changed.sort(key=lambda e: e["url"])
|
|
540
|
+
unchanged.sort(key=lambda e: e["url"])
|
|
541
|
+
fetch_failed.sort(key=lambda e: e["url"])
|
|
542
|
+
skipped_null_hash.sort(key=lambda e: e["url"])
|
|
543
|
+
|
|
544
|
+
total = len(changed) + len(unchanged) + len(fetch_failed) + len(skipped_null_hash)
|
|
545
|
+
return {
|
|
546
|
+
"changed": changed,
|
|
547
|
+
"unchanged": unchanged,
|
|
548
|
+
"fetch_failed": fetch_failed,
|
|
549
|
+
"skipped_null_hash": skipped_null_hash,
|
|
550
|
+
"stats": {
|
|
551
|
+
"total_tracked": total,
|
|
552
|
+
"changed": len(changed),
|
|
553
|
+
"unchanged": len(unchanged),
|
|
554
|
+
"fetch_failed": len(fetch_failed),
|
|
555
|
+
"skipped_null_hash": len(skipped_null_hash),
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _cmd_compare_hashes(argv: List[str]) -> int:
|
|
561
|
+
parser = argparse.ArgumentParser(
|
|
562
|
+
prog="skf-detect-docs.py compare-hashes",
|
|
563
|
+
description=(
|
|
564
|
+
"Re-fetch each tracked doc URL, hash the response bytes, and "
|
|
565
|
+
"compare against the stored content_hash to detect doc drift."
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
parser.add_argument(
|
|
569
|
+
"source",
|
|
570
|
+
help=(
|
|
571
|
+
"path to a JSON file (array of {url, content_hash} entries, or an "
|
|
572
|
+
"object with a doc_sources array such as metadata.json), or - to "
|
|
573
|
+
"read that JSON from stdin"
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
args = parser.parse_args(argv)
|
|
577
|
+
|
|
578
|
+
if args.source == "-":
|
|
579
|
+
raw = sys.stdin.read()
|
|
580
|
+
label = "<stdin>"
|
|
581
|
+
else:
|
|
582
|
+
path = Path(args.source)
|
|
583
|
+
try:
|
|
584
|
+
raw = path.read_text(encoding="utf-8")
|
|
585
|
+
except OSError as exc:
|
|
586
|
+
json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
|
|
587
|
+
sys.stderr.write("\n")
|
|
588
|
+
return 2
|
|
589
|
+
label = str(path)
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
doc_sources = _load_doc_sources(raw, label)
|
|
593
|
+
except ValueError as exc:
|
|
594
|
+
json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
|
|
595
|
+
sys.stderr.write("\n")
|
|
596
|
+
return 2
|
|
597
|
+
|
|
598
|
+
result = compare_doc_hashes(doc_sources)
|
|
599
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
600
|
+
sys.stdout.write("\n")
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
|
|
374
604
|
# ---------------------------------------------------------------------------
|
|
375
605
|
# CLI entry point
|
|
376
606
|
# ---------------------------------------------------------------------------
|
|
377
607
|
|
|
378
608
|
def main() -> int:
|
|
609
|
+
# Additive subcommand: route `compare-hashes ...` to the doc-drift path
|
|
610
|
+
# before the existing detect parser runs. The default (no subcommand)
|
|
611
|
+
# invocation `--repo-url <url> ...` is unchanged.
|
|
612
|
+
if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
|
|
613
|
+
return _cmd_compare_hashes(sys.argv[2:])
|
|
614
|
+
|
|
379
615
|
parser = argparse.ArgumentParser(
|
|
380
616
|
description="Detect documentation URLs for a GitHub repository.",
|
|
381
617
|
)
|
|
@@ -32,9 +32,10 @@ Detection rules (apply in order, first match wins):
|
|
|
32
32
|
7. build.gradle (Groovy) — check tree:
|
|
33
33
|
src/main/kotlin/ → kotlin (medium)
|
|
34
34
|
else → java (medium)
|
|
35
|
-
8.
|
|
36
|
-
9.
|
|
37
|
-
10.
|
|
35
|
+
8. Package.swift → swift (high)
|
|
36
|
+
9. *.csproj | *.sln → csharp (high)
|
|
37
|
+
10. Gemfile → ruby (high)
|
|
38
|
+
11. Extension-frequency fallback over the full tree.
|
|
38
39
|
dominant extension >= 50% of code files → that language (medium)
|
|
39
40
|
no clear winner → unknown (low)
|
|
40
41
|
|
|
@@ -57,6 +58,16 @@ Output (JSON on stdout):
|
|
|
57
58
|
detection_source — human-readable string naming what fired (manifest
|
|
58
59
|
basename, extension share, etc.)
|
|
59
60
|
fallback_to_extension_frequency — bool (true when rule 10 fired)
|
|
61
|
+
detected_languages — ordered, deduplicated list of EVERY manifest-level
|
|
62
|
+
match in the same priority order the winner walk uses.
|
|
63
|
+
language == detected_languages[0] whenever the manifest
|
|
64
|
+
table fired, so a caller can auto-pick detected_languages[0]
|
|
65
|
+
and gate multi-language disambiguation on
|
|
66
|
+
len(detected_languages) > 1. Special cases: a
|
|
67
|
+
workspace_signal override (rule 0) is decisive and returns
|
|
68
|
+
a single-element [language]; the extension-frequency
|
|
69
|
+
fallback (rule 10) contributes [language] for a best guess
|
|
70
|
+
or [] when language is "unknown".
|
|
60
71
|
|
|
61
72
|
Exit codes:
|
|
62
73
|
0 — recommendation produced (even when language is "unknown")
|
|
@@ -94,6 +105,7 @@ _MANIFEST_RULES: list[tuple[str, str, str]] = [
|
|
|
94
105
|
("go.mod", "go", "go.mod present"),
|
|
95
106
|
("pom.xml", "java", "pom.xml present"),
|
|
96
107
|
("build.gradle.kts", "kotlin", "build.gradle.kts present"),
|
|
108
|
+
("Package.swift", "swift", "Package.swift present"),
|
|
97
109
|
("Gemfile", "ruby", "Gemfile present"),
|
|
98
110
|
]
|
|
99
111
|
|
|
@@ -199,7 +211,74 @@ def _frequency_fallback(tree: list[str]) -> dict[str, Any]:
|
|
|
199
211
|
}
|
|
200
212
|
|
|
201
213
|
|
|
214
|
+
def _detected_languages(payload: dict[str, Any], winner: dict[str, Any]) -> list[str]:
|
|
215
|
+
"""Accumulate every manifest-level match into an ordered, deduplicated list.
|
|
216
|
+
|
|
217
|
+
The walk mirrors the winner-selection priority order exactly, so
|
|
218
|
+
winner["language"] == detected_languages[0] whenever the manifest table
|
|
219
|
+
fired. Two decisive short-circuits diverge from a full walk on purpose:
|
|
220
|
+
|
|
221
|
+
* A workspace_signal override (rule 0) is authoritative — the workspace
|
|
222
|
+
root language wins over any nested package.json, so this returns a
|
|
223
|
+
single-element [winner_language] and never surfaces a spurious
|
|
224
|
+
multi-language gate for what workspace detection already resolved.
|
|
225
|
+
* When no manifest matched (rule 10 fired), the list carries the
|
|
226
|
+
extension-frequency best guess as a single element, or [] when the
|
|
227
|
+
guess was "unknown".
|
|
228
|
+
"""
|
|
229
|
+
tree: list[str] = payload["tree"] # validated non-empty by _winner()
|
|
230
|
+
|
|
231
|
+
workspace_signal = payload.get("workspace_signal")
|
|
232
|
+
if isinstance(workspace_signal, str) and workspace_signal in _WORKSPACE_SIGNAL_LANGUAGE:
|
|
233
|
+
return [winner["language"]]
|
|
234
|
+
|
|
235
|
+
langs: list[str] = []
|
|
236
|
+
|
|
237
|
+
def add(lang: str) -> None:
|
|
238
|
+
if lang not in langs:
|
|
239
|
+
langs.append(lang)
|
|
240
|
+
|
|
241
|
+
# Rule 1 — package.json (tsconfig.json disambiguation)
|
|
242
|
+
if _has_basename(tree, "package.json"):
|
|
243
|
+
add("typescript" if _has_basename(tree, "tsconfig.json") else "javascript")
|
|
244
|
+
|
|
245
|
+
# Rules 2-6 / 8 / 10 — single-basename manifests (same order as _MANIFEST_RULES)
|
|
246
|
+
for basename, language, _source in _MANIFEST_RULES:
|
|
247
|
+
if _has_basename(tree, basename):
|
|
248
|
+
add(language)
|
|
249
|
+
|
|
250
|
+
# Rule 7b — build.gradle (Groovy) Java/Kotlin disambiguation
|
|
251
|
+
if _has_basename(tree, "build.gradle"):
|
|
252
|
+
add("kotlin" if _has_path_segment(tree, "src/main/kotlin/") else "java")
|
|
253
|
+
|
|
254
|
+
# Rules 8-9 — suffix-based (csproj, sln)
|
|
255
|
+
for suffix, language, _source in _SUFFIX_RULES:
|
|
256
|
+
if _has_suffix(tree, suffix):
|
|
257
|
+
add(language)
|
|
258
|
+
|
|
259
|
+
if langs:
|
|
260
|
+
return langs
|
|
261
|
+
|
|
262
|
+
# No manifest matched — the winner came from the extension-frequency
|
|
263
|
+
# fallback (rule 10). Carry its best guess, or [] when it was "unknown".
|
|
264
|
+
fallback_language = winner["language"]
|
|
265
|
+
return [] if fallback_language == "unknown" else [fallback_language]
|
|
266
|
+
|
|
267
|
+
|
|
202
268
|
def detect(payload: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
"""Apply the documented rule walk and annotate the full manifest match set.
|
|
270
|
+
|
|
271
|
+
Returns the single-winner envelope (language/confidence/detection_source/
|
|
272
|
+
fallback_to_extension_frequency) unchanged, plus detected_languages[] — the
|
|
273
|
+
ordered, deduplicated set of every manifest-level match — so a caller can
|
|
274
|
+
both auto-pick and gate multi-language disambiguation off one JSON shape.
|
|
275
|
+
"""
|
|
276
|
+
winner = _winner(payload)
|
|
277
|
+
winner["detected_languages"] = _detected_languages(payload, winner)
|
|
278
|
+
return winner
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _winner(payload: dict[str, Any]) -> dict[str, Any]:
|
|
203
282
|
"""Apply the documented rule walk. Always returns a recommendation."""
|
|
204
283
|
tree = payload.get("tree")
|
|
205
284
|
if not isinstance(tree, list):
|
|
@@ -79,6 +79,25 @@ DETECT_OUTPUT_SCHEMA (v1):
|
|
|
79
79
|
"requested": "Quick"|"Forge"|"Forge+"|"Deep"|null,
|
|
80
80
|
"satisfied": bool|null,
|
|
81
81
|
"missing_tools": [str]
|
|
82
|
+
},
|
|
83
|
+
"prior": {
|
|
84
|
+
# Populated from --prior-state-from's forge-tier.yaml (first run: all null/empty).
|
|
85
|
+
"previous_tier": "Quick"|"Forge"|"Forge+"|"Deep"|null,
|
|
86
|
+
"previous_detection_date": str|null,
|
|
87
|
+
"previous_tools": {tool: bool, ...},
|
|
88
|
+
"previous_ccc_index_status": str|null,
|
|
89
|
+
"previous_ccc_indexed_path": str|null,
|
|
90
|
+
"previous_ccc_last_indexed": str|null,
|
|
91
|
+
"previous_ccc_staleness_threshold_hours": int|null,
|
|
92
|
+
# Deterministic CCC-index freshness verdict — computed against
|
|
93
|
+
# --project-root and datetime.now(UTC) captured at detect time, so the
|
|
94
|
+
# ccc-index.md step branches on a boolean instead of doing timestamp math.
|
|
95
|
+
"ccc_index_fresh": bool
|
|
96
|
+
},
|
|
97
|
+
"deltas": {
|
|
98
|
+
"tools_added": [str],
|
|
99
|
+
"tools_removed": [str],
|
|
100
|
+
"tier_changed": bool
|
|
82
101
|
}
|
|
83
102
|
}
|
|
84
103
|
"""
|
|
@@ -93,6 +112,7 @@ import subprocess
|
|
|
93
112
|
import sys
|
|
94
113
|
import tempfile
|
|
95
114
|
from concurrent.futures import ThreadPoolExecutor
|
|
115
|
+
from datetime import datetime, timezone
|
|
96
116
|
|
|
97
117
|
|
|
98
118
|
VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
@@ -219,8 +239,13 @@ def probe_ccc() -> dict:
|
|
|
219
239
|
# `ccc` resolved to a foreign binary (e.g. code2prompt alias). Refuse.
|
|
220
240
|
return {"available": False, "daemon": None, "version": None}
|
|
221
241
|
|
|
222
|
-
rc_doctor,
|
|
223
|
-
version
|
|
242
|
+
rc_doctor, _, _ = _run(["ccc", "doctor"])
|
|
243
|
+
# ccc exposes no version CLI: `ccc --version` prints a usage banner, and
|
|
244
|
+
# `ccc doctor` / `ccc --help` lead with a settings header ("Global
|
|
245
|
+
# Settings") or usage line — never a version string. Capturing that first
|
|
246
|
+
# line mislabels a header as a version, so report daemon health (below)
|
|
247
|
+
# as ccc's identifier instead of a misparse.
|
|
248
|
+
version = None
|
|
224
249
|
if rc_doctor == 0:
|
|
225
250
|
return {"available": True, "daemon": "healthy", "version": version}
|
|
226
251
|
# Distinguishing "stopped" from "error" requires parsing doctor output;
|
|
@@ -337,6 +362,73 @@ def read_prior_state(prior_state_path) -> dict:
|
|
|
337
362
|
}
|
|
338
363
|
|
|
339
364
|
|
|
365
|
+
def _parse_iso_timestamp(value) -> datetime | None:
|
|
366
|
+
"""Parse an ISO 8601 timestamp into a timezone-aware datetime, or None.
|
|
367
|
+
|
|
368
|
+
Normalizes a trailing 'Z' (UTC designator) to '+00:00' because
|
|
369
|
+
`datetime.fromisoformat` does not accept 'Z' on Python < 3.11 and
|
|
370
|
+
requires-python here is >=3.10. A naive result (no tzinfo) is assumed to be
|
|
371
|
+
UTC so it can be compared against a tz-aware `now` without raising. Returns
|
|
372
|
+
None on any parse failure (non-string, empty, malformed).
|
|
373
|
+
"""
|
|
374
|
+
if not isinstance(value, str):
|
|
375
|
+
return None
|
|
376
|
+
text = value.strip()
|
|
377
|
+
if not text:
|
|
378
|
+
return None
|
|
379
|
+
if text[-1] in ("Z", "z"):
|
|
380
|
+
text = text[:-1] + "+00:00"
|
|
381
|
+
try:
|
|
382
|
+
dt = datetime.fromisoformat(text)
|
|
383
|
+
except (ValueError, TypeError):
|
|
384
|
+
return None
|
|
385
|
+
if dt.tzinfo is None:
|
|
386
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
387
|
+
return dt
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def compute_ccc_index_fresh(prior: dict, project_root, now: datetime) -> bool:
|
|
391
|
+
"""Deterministic freshness verdict for a prior CCC index.
|
|
392
|
+
|
|
393
|
+
Replaces the ISO-8601 datetime arithmetic that ccc-index.md §2 used to ask
|
|
394
|
+
the model to perform (parse a timestamp, subtract from now, convert to
|
|
395
|
+
hours, compare against a threshold). Same inputs → same boolean.
|
|
396
|
+
|
|
397
|
+
Returns True only when ALL hold:
|
|
398
|
+
- the prior index covered this same project (indexed_path == project_root)
|
|
399
|
+
- the prior index status is "fresh" or "created"
|
|
400
|
+
- last_indexed parses AND (now - last_indexed) <= staleness threshold
|
|
401
|
+
|
|
402
|
+
The staleness threshold defaults to 24 hours when the prior field is null
|
|
403
|
+
(matching the ccc-index.md §2 default). Any null/unparseable required field
|
|
404
|
+
(indexed_path, status, last_indexed), path mismatch, non-fresh status,
|
|
405
|
+
unparseable threshold, or over-threshold delta yields False.
|
|
406
|
+
"""
|
|
407
|
+
if not project_root:
|
|
408
|
+
return False
|
|
409
|
+
if prior.get("previous_ccc_indexed_path") != project_root:
|
|
410
|
+
return False
|
|
411
|
+
if prior.get("previous_ccc_index_status") not in ("fresh", "created"):
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
last_indexed = _parse_iso_timestamp(prior.get("previous_ccc_last_indexed"))
|
|
415
|
+
if last_indexed is None:
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
threshold = prior.get("previous_ccc_staleness_threshold_hours")
|
|
419
|
+
if threshold is None:
|
|
420
|
+
threshold = 24
|
|
421
|
+
try:
|
|
422
|
+
threshold_hours = float(threshold)
|
|
423
|
+
except (ValueError, TypeError):
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
if now.tzinfo is None:
|
|
427
|
+
now = now.replace(tzinfo=timezone.utc)
|
|
428
|
+
delta_hours = (now - last_indexed).total_seconds() / 3600.0
|
|
429
|
+
return delta_hours <= threshold_hours
|
|
430
|
+
|
|
431
|
+
|
|
340
432
|
def detect(args: argparse.Namespace) -> dict:
|
|
341
433
|
tools: dict = {}
|
|
342
434
|
with ThreadPoolExecutor(max_workers=4) as ex:
|
|
@@ -389,6 +481,9 @@ def detect(args: argparse.Namespace) -> dict:
|
|
|
389
481
|
require_satisfied = None
|
|
390
482
|
|
|
391
483
|
prior = read_prior_state(getattr(args, "prior_state_from", None))
|
|
484
|
+
prior["ccc_index_fresh"] = compute_ccc_index_fresh(
|
|
485
|
+
prior, getattr(args, "project_root", None), datetime.now(timezone.utc)
|
|
486
|
+
)
|
|
392
487
|
deltas = compute_deltas(tools, prior, calculated)
|
|
393
488
|
|
|
394
489
|
return {
|
|
@@ -470,6 +565,14 @@ def main() -> None:
|
|
|
470
565
|
help="Environment variable name to check for security-scan availability"
|
|
471
566
|
" (informational only — does NOT affect tier). Default: SNYK_TOKEN.",
|
|
472
567
|
)
|
|
568
|
+
parser.add_argument(
|
|
569
|
+
"--project-root",
|
|
570
|
+
default=None,
|
|
571
|
+
help="Absolute project root of the current run. Used only to compute"
|
|
572
|
+
" prior.ccc_index_fresh: the prior CCC index counts as fresh only"
|
|
573
|
+
" when its indexed_path equals this value (and its status/timestamp"
|
|
574
|
+
" still qualify). Omitted → ccc_index_fresh is always false.",
|
|
575
|
+
)
|
|
473
576
|
parser.add_argument(
|
|
474
577
|
"--prior-state-from",
|
|
475
578
|
default=None,
|
|
@@ -54,9 +54,17 @@ Context payload shape (consumed by `emit`):
|
|
|
54
54
|
"ccc_exclusion_warnings": ["string", ...],
|
|
55
55
|
"ccc_registry_stale_removed": ["/path", ...],
|
|
56
56
|
"ccc_indexing_failed_reason": "string|null",
|
|
57
|
+
"orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|orphan-action-flag"},
|
|
57
58
|
"error": null|{"phase","path","reason"}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
When the step-3 orphan-removal gate is resolved non-interactively
|
|
62
|
+
(headless default Keep, or an explicit --orphan-action), pass
|
|
63
|
+
`orphan_auto_resolution` so the audit trail lands in `warnings` —
|
|
64
|
+
most importantly when the destructive `remove` ran headlessly, which a
|
|
65
|
+
pipeline otherwise could not distinguish from a no-op by reading the
|
|
66
|
+
envelope alone.
|
|
67
|
+
|
|
60
68
|
Caller does NOT need to compute warnings, tools_added/removed, or
|
|
61
69
|
tier_changed — the script derives them from the inputs above.
|
|
62
70
|
|
|
@@ -176,6 +184,15 @@ def _assemble_warnings(payload: dict) -> list[str]:
|
|
|
176
184
|
warnings.append(
|
|
177
185
|
f"require_tier_failed: missing {', '.join(missing) if missing else '<none>'}"
|
|
178
186
|
)
|
|
187
|
+
orphan = payload.get("orphan_auto_resolution")
|
|
188
|
+
if isinstance(orphan, dict) and orphan.get("action"):
|
|
189
|
+
action = str(orphan.get("action"))
|
|
190
|
+
count = orphan.get("count", 0)
|
|
191
|
+
source = str(orphan.get("source", "headless-default"))
|
|
192
|
+
warnings.append(
|
|
193
|
+
f"orphan_auto_resolution: {action} {count} orphaned collection(s) "
|
|
194
|
+
f"(non-interactive, {source})"
|
|
195
|
+
)
|
|
179
196
|
return warnings
|
|
180
197
|
|
|
181
198
|
|