bmad-module-skill-forge 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -1
- package/docs/404.md +4 -4
- package/docs/_data/pinned.yaml +1 -1
- package/docs/_internal/RELEASING.md +41 -4
- package/docs/agents.md +2 -2
- package/docs/architecture.md +1 -1
- package/docs/bmad-synergy.md +13 -13
- package/docs/campaign.md +3 -3
- package/docs/concepts.md +3 -3
- package/docs/examples.md +9 -9
- package/docs/forge-auto.md +6 -6
- package/docs/getting-started.md +12 -12
- package/docs/how-it-works.md +7 -7
- package/docs/index.md +7 -1
- package/docs/skill-model.md +2 -2
- package/docs/troubleshooting.md +7 -7
- package/docs/why-skf.md +8 -8
- package/docs/workflows.md +5 -5
- package/package.json +7 -3
- package/src/shared/references/description-guard-protocol.md +2 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
- package/src/shared/scripts/skf-description-guard.py +22 -2
- package/src/shared/scripts/skf-detect-docs.py +198 -2
- package/src/shared/scripts/skf-detect-language.py +20 -0
- package/src/shared/scripts/skf-detect-tools.py +40 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
- package/src/shared/scripts/skf-language-corpora.py +20 -0
- package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
- package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
- package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-shape-detect.py +20 -0
- package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
- package/src/skf-analyze-source/references/recommend.md +1 -1
- package/src/skf-audit-skill/references/re-index.md +1 -1
- package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/analyze-target.md +2 -2
- package/src/skf-brief-skill/references/gather-intent.md +4 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
- package/src/skf-create-skill/assets/skill-sections.md +3 -2
- package/src/skf-create-skill/references/compile.md +12 -3
- package/src/skf-create-skill/references/extract.md +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +5 -3
- package/src/skf-create-skill/references/step-doc-rot.md +23 -6
- package/src/skf-create-skill/references/step-doc-sources.md +31 -3
- package/src/skf-create-skill/references/sub/fetch-docs.md +4 -2
- package/src/skf-create-skill/references/validate.md +2 -2
- package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
- package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
- package/src/skf-setup/references/ccc-index.md +5 -4
- package/src/skf-setup/references/detect-and-tier.md +1 -0
- package/src/skf-setup/references/report.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +11 -5
- package/src/skf-test-skill/references/score.md +7 -1
- package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
- package/src/skf-test-skill/scripts/compute-score.py +17 -4
- package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
- package/src/skf-update-skill/references/write.md +2 -2
- package/tools/cli/lib/installer.js +31 -1
- package/tools/validate-docs-links.js +665 -0
package/docs/troubleshooting.md
CHANGED
|
@@ -3,7 +3,7 @@ title: Troubleshooting
|
|
|
3
3
|
description: Common errors in Skill Forge — forge setup, ecosystem checks, tier confidence — and how to resolve them.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
If something isn't working, start here. For general setup help see [Getting Started → Need help?](
|
|
6
|
+
If something isn't working, start here. For general setup help see [Getting Started → Need help?](/docs/getting-started.md#need-help).
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -13,13 +13,13 @@ If something isn't working, start here. For general setup help see [Getting Star
|
|
|
13
13
|
|
|
14
14
|
Surfaced by `/skf-setup` On Activation when `uv --version` is missing on `$PATH`. SKF helpers depend on `uv` to auto-resolve their Python dependencies via PEP 723 inline metadata; bare `python3` ignores that metadata and would fail later with `ModuleNotFoundError: No module named 'yaml'`. The probe halts the workflow up-front with one cohesive diagnostic instead of letting five steps each fail individually.
|
|
15
15
|
|
|
16
|
-
**Fix:** install `uv` from <https://docs.astral.sh/uv/getting-started/installation/> and re-run `/skf-setup`. `uv` is documented as a runtime prerequisite in [Getting Started → Prerequisites](
|
|
16
|
+
**Fix:** install `uv` from <https://docs.astral.sh/uv/getting-started/installation/> and re-run `/skf-setup`. `uv` is documented as a runtime prerequisite in [Getting Started → Prerequisites](/docs/getting-started.md#prerequisites-full-reference).
|
|
17
17
|
|
|
18
18
|
### "Setup cannot proceed: `_bmad/skf/config.yaml` was not found"
|
|
19
19
|
|
|
20
20
|
Surfaced by `/skf-setup` On Activation when the SKF install config is missing — typically because you invoked `/skf-setup` from a directory that is not an SKF-initialised project. The check runs before any file mutation so nothing is written.
|
|
21
21
|
|
|
22
|
-
**Fix:** from the project root, run `npx bmad-module-skill-forge install` (or `npx bmad-method install` and add SKF as a custom module — see [Getting Started → Install](
|
|
22
|
+
**Fix:** from the project root, run `npx bmad-module-skill-forge install` (or `npx bmad-method install` and add SKF as a custom module — see [Getting Started → Install](/docs/getting-started.md#install)), then re-run `/skf-setup`. If you ARE in the right project but the file was deleted, restore it from version control or re-run the SKF installer. A separate "config.yaml is not valid YAML" diagnostic surfaces the parser error inline if the file exists but is malformed — open the file at the named path and repair the YAML.
|
|
23
23
|
|
|
24
24
|
### Forge reports ast-grep is unavailable
|
|
25
25
|
|
|
@@ -35,7 +35,7 @@ An official skill already exists for this package. Consider installing it with `
|
|
|
35
35
|
|
|
36
36
|
### Quick-tier skills have lower confidence scores
|
|
37
37
|
|
|
38
|
-
Quick tier reads source without AST analysis, so signatures are read directly from files rather than structurally verified. Install ast-grep to upgrade to the Forge tier for AST-verified signatures (T1 confidence) — see [Capability Tiers](
|
|
38
|
+
Quick tier reads source without AST analysis, so signatures are read directly from files rather than structurally verified. Install ast-grep to upgrade to the Forge tier for AST-verified signatures (T1 confidence) — see [Capability Tiers](/docs/concepts.md#capability-tiers-quickforgeforgedeep).
|
|
39
39
|
|
|
40
40
|
### Want semantic discovery for large codebases?
|
|
41
41
|
|
|
@@ -43,11 +43,11 @@ Install [cocoindex-code](https://github.com/cocoindex-io/cocoindex-code) to unlo
|
|
|
43
43
|
|
|
44
44
|
### `@Ferris deepwiki` shows a deprecation notice
|
|
45
45
|
|
|
46
|
-
The auto pipeline was briefly named `deepwiki`; it's now [`forge-auto`](
|
|
46
|
+
The auto pipeline was briefly named `deepwiki`; it's now [`forge-auto`](/docs/forge-auto.md) — renamed to avoid confusion with the DeepWiki MCP, since the pipeline compiles a verified skill from source and does **not** call that MCP. `deepwiki` still works (it resolves to `forge-auto`) but prints a one-time notice. Switch your commands to `@Ferris forge-auto <repo-or-doc-url>`.
|
|
47
47
|
|
|
48
48
|
### `@Ferris onboard` returns an error
|
|
49
49
|
|
|
50
|
-
The `onboard` alias was removed. Its replacement is [`forge-auto`](
|
|
50
|
+
The `onboard` alias was removed. Its replacement is [`forge-auto`](/docs/forge-auto.md), which does everything `onboard` did plus auto-scope, auto-brief, and a stricter 90% quality gate. Run `@Ferris forge-auto <repo-or-doc-url>` instead.
|
|
51
51
|
|
|
52
52
|
### forge-auto halted at the Test stage
|
|
53
53
|
|
|
@@ -64,4 +64,4 @@ Campaign is designed for exactly this. State lives in `_campaign-state.yaml` on
|
|
|
64
64
|
1. Run `@Ferris SF` to check your tool availability and current tier
|
|
65
65
|
2. Check `forge-tier.yaml` in your forger sidecar for your configuration
|
|
66
66
|
3. If `/bmad-help` is installed (via full BMAD Method), run it and describe your state — e.g. `/bmad-help my batch creation failed halfway, how do I resume?`
|
|
67
|
-
4. [File an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) — SKF's [health check system](
|
|
67
|
+
4. [File an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) — SKF's [health check system](/docs/workflows.md#terminal-step-health-check) is the primary feedback channel, and manual issues feed the same pipeline
|
package/docs/why-skf.md
CHANGED
|
@@ -44,7 +44,7 @@ For docs-only skills, the audit shape is the same — `provenance-map.json` stil
|
|
|
44
44
|
|
|
45
45
|
If it doesn't, **that's a bug.** [Open an issue](https://github.com/armelhbobdad/bmad-module-skill-forge/issues/new/choose) and SKF republishes the skill with a new commit SHA and a new provenance map. No other AI-skills tool treats disagreement between claim and source as a defect. SKF does.
|
|
46
46
|
|
|
47
|
-
See the [Verifying a Skill](
|
|
47
|
+
See the [Verifying a Skill](/docs/verifying-a-skill.md) page for the full three-step audit on real skills, the test reports that log *exactly* where coverage falls short, and the scoring formula behind the 80% pass threshold.
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
@@ -53,27 +53,27 @@ See the [Verifying a Skill](../verifying-a-skill/) page for the full three-step
|
|
|
53
53
|
### The curious developer
|
|
54
54
|
|
|
55
55
|
Your agent just hallucinated a method that doesn't exist, again. You want this to stop, and you don't want to read a long architecture reference before running your first command.
|
|
56
|
-
→ Start with [Getting Started](
|
|
56
|
+
→ Start with [Getting Started](/docs/getting-started.md).
|
|
57
57
|
|
|
58
58
|
### The BMAD user
|
|
59
59
|
|
|
60
60
|
You already use BMAD Method, BMM phases, TEA, or BMB, and you want to know where SKF fits.
|
|
61
|
-
→ Read [BMAD Synergy](
|
|
61
|
+
→ Read [BMAD Synergy](/docs/bmad-synergy.md) for the phase-by-phase integration playbook.
|
|
62
62
|
|
|
63
63
|
### The skeptic
|
|
64
64
|
|
|
65
65
|
"AI docs for AI" sounds like the problem pretending to be the solution. You want receipts before you install anything.
|
|
66
|
-
→ Start with [Verifying a Skill](
|
|
66
|
+
→ Start with [Verifying a Skill](/docs/verifying-a-skill.md) — the three-step audit on real skills, including the 1% that fails.
|
|
67
67
|
|
|
68
68
|
### The OSS maintainer
|
|
69
69
|
|
|
70
70
|
You want to ship verified skills alongside your library releases — `npx skills publish`-ready, drift-detectable, version-pinned.
|
|
71
|
-
→ See [Examples → OSS Maintainer Publishing Official Skills](
|
|
71
|
+
→ See [Examples → OSS Maintainer Publishing Official Skills](/docs/examples.md#scenario-h-oss-maintainer-publishing-official-skills).
|
|
72
72
|
|
|
73
73
|
### The team lead evaluating adoption
|
|
74
74
|
|
|
75
75
|
You're considering running SKF across a brownfield platform. You need to know about rollback safety, `[MANUAL]` section preservation, and the health-check feedback loop before committing.
|
|
76
|
-
→ Start with [Architecture](
|
|
76
|
+
→ Start with [Architecture](/docs/architecture.md), then [Workflows → Workflow Health Check](/docs/workflows.md#terminal-step-health-check).
|
|
77
77
|
|
|
78
78
|
---
|
|
79
79
|
|
|
@@ -89,6 +89,6 @@ Everything else is downstream of one question: *are the instructions your AI rea
|
|
|
89
89
|
|
|
90
90
|
## Next
|
|
91
91
|
|
|
92
|
-
- **[Install SKF](
|
|
93
|
-
- **[Audit a skill in 60 seconds](
|
|
92
|
+
- **[Install SKF](/docs/getting-started.md#install)** — Node ≥ 22, Python ≥ 3.10, `uv`, one `npx` command
|
|
93
|
+
- **[Audit a skill in 60 seconds](/docs/verifying-a-skill.md)** — see the receipts before you install
|
|
94
94
|
- **[Browse real skills](https://github.com/armelhbobdad/oh-my-skills)** — four Deep-tier skills, all shipping their audit trails
|
package/docs/workflows.md
CHANGED
|
@@ -3,9 +3,9 @@ title: Workflows
|
|
|
3
3
|
description: All 15 SKF workflows with commands, steps, and connection diagram
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Trigger workflows by typing commands to [Ferris](
|
|
6
|
+
Trigger workflows by typing commands to [Ferris](/docs/agents.md). See [Concepts](/docs/concepts.md) for definitions.
|
|
7
7
|
|
|
8
|
-
> Already using BMAD? See [BMAD Synergy](
|
|
8
|
+
> Already using BMAD? See [BMAD Synergy](/docs/bmad-synergy.md) for when to invoke each SKF workflow during BMM phases and alongside TEA, BMB, and GDS.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -83,7 +83,7 @@ Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../
|
|
|
83
83
|
|
|
84
84
|
**Purpose:** Brief-less fast skill with package-to-repo resolution.
|
|
85
85
|
|
|
86
|
-
**Note:** QS is **tier-unaware** — it always runs at community tier and does not use ast-grep, CCC, or QMD even when your forge is configured at Forge+/Deep. For tier-aware compilation, use `BS → CS`, `forge`, or `forge-auto`. See [Skill Model](
|
|
86
|
+
**Note:** QS is **tier-unaware** — it always runs at community tier and does not use ast-grep, CCC, or QMD even when your forge is configured at Forge+/Deep. For tier-aware compilation, use `BS → CS`, `forge`, or `forge-auto`. See [Skill Model](/docs/skill-model.md).
|
|
87
87
|
|
|
88
88
|
**When to Use:** When you need a skill quickly — no brief needed. Accepts package names or GitHub URLs. Append `@version` to target a specific version (e.g., `@Ferris QS cognee@1.0.0`).
|
|
89
89
|
|
|
@@ -164,7 +164,7 @@ Trigger workflows by typing commands to [Ferris](../agents/). See [Concepts](../
|
|
|
164
164
|
|
|
165
165
|
**Key Steps:** Load skill → Detect mode → Coverage check → Coherence check → External validation (skill-check, tessl) → Hard gate → Score → Gap report
|
|
166
166
|
|
|
167
|
-
**Scored Categories:** Export Coverage (36%), Signature Accuracy (22%), Type Coverage (14%), Coherence (18%), External Validation (10%). Default pass threshold: **80%** (per-pipeline defaults: forge-auto 90%, forge 80%). Pass routes to Export Skill; fail routes to Update Skill with a gap report. See [Completeness Scoring](
|
|
167
|
+
**Scored Categories:** Export Coverage (36%), Signature Accuracy (22%), Type Coverage (14%), Coherence (18%), External Validation (10%). Default pass threshold: **80%** (per-pipeline defaults: forge-auto 90%, forge 80%). Pass routes to Export Skill; fail routes to Update Skill with a gap report. See [Completeness Scoring](/docs/verifying-a-skill.md#how-the-score-is-computed) for the full formula and tier adjustments.
|
|
168
168
|
|
|
169
169
|
**Agent:** Ferris (Audit mode)
|
|
170
170
|
|
|
@@ -315,7 +315,7 @@ flowchart TD
|
|
|
315
315
|
EXPORT --> MAINT[Maintenance + Campaign Report]
|
|
316
316
|
```
|
|
317
317
|
|
|
318
|
-
> **One workflow per session** (unless using pipeline mode or campaign). Each arrow in the standard and compose-mode diagrams represents a new conversation session. Campaign manages its own multi-session orchestration internally — see [Campaign Orchestration](
|
|
318
|
+
> **One workflow per session** (unless using pipeline mode or campaign). Each arrow in the standard and compose-mode diagrams represents a new conversation session. Campaign manages its own multi-session orchestration internally — see [Campaign Orchestration](/docs/campaign.md). Clear your context between workflows for best results — or use pipeline mode to chain them automatically. See [Pipeline Mode](#pipeline-mode) below.
|
|
319
319
|
|
|
320
320
|
---
|
|
321
321
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-module-skill-forge",
|
|
4
|
-
"version": "2.0
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"bmad",
|
|
@@ -44,18 +44,21 @@
|
|
|
44
44
|
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
|
45
45
|
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
|
46
46
|
"prepare": "husky || exit 0",
|
|
47
|
-
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run docs:validate-drift",
|
|
47
|
+
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:rehype && npm run test:docs-links-tool && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run validate:docs-links && npm run docs:validate-drift",
|
|
48
48
|
"skf:install": "node tools/cli/skf-cli.js install",
|
|
49
49
|
"skf:status": "node tools/cli/skf-cli.js status",
|
|
50
50
|
"skf:uninstall": "node tools/cli/skf-cli.js uninstall",
|
|
51
51
|
"skf:update": "node tools/cli/skf-cli.js update",
|
|
52
|
-
"test": "npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run lint && npm run lint:md && npm run format:check",
|
|
52
|
+
"test": "npm run test:schemas && npm run test:install && npm run test:cli && npm run test:workflow && npm run test:python && npm run test:rehype && npm run test:docs-links-tool && npm run test:knowledge && npm run validate:schemas && npm run validate:skills && npm run validate:refs && npm run validate:docs-links && npm run lint && npm run lint:md && npm run format:check",
|
|
53
53
|
"test:cli": "node test/test-cli-integration.js",
|
|
54
|
+
"test:docs-links-tool": "node test/test-validate-docs-links.js",
|
|
54
55
|
"test:install": "node test/test-installation-components.js",
|
|
55
56
|
"test:knowledge": "node test/test-knowledge-base.js",
|
|
56
57
|
"test:python": "uv run --with pytest --with pyyaml --with jsonschema pytest test/test-compute-score-contract.py test/test-skf-preflight.py test/test-skf-skill-inventory.py test/test-skf-validate-output.py test/test-skf-validate-frontmatter.py test/test-skf-manifest-ops.py test/test-skf-rebuild-managed-sections.py test/test-skf-atomic-write.py test/test-skf-severity-classify.py test/test-skf-structural-diff.py test/test-skf-detect-tools.py test/test-skf-forge-tier-rw.py test/test-skf-emit-result-envelope.py test/test-skf-qmd-classify-collections.py test/test-skf-merge-ccc-exclusions.py test/test-skf-resolve-package.py test/test-skf-extract-public-api.py test/test-skf-render-quick-metadata.py test/test-skf-validate-brief-inputs.py test/test-skf-emit-brief-result-envelope.py test/test-skf-write-skill-brief.py test/test-skf-detect-workspaces.py test/test-skf-recommend-scope-type.py test/test-skf-detect-language.py test/test-skf-language-corpora.py test/test-skf-merge-doc-urls.py test/test-skf-derive-assembly-shape.py test/test-skf-description-guard.py test/test-skf-detect-scripts-assets.py test/test-skf-hash-content.py test/test-skf-validate-brief-schema.py test/test-skf-check-workspace-drift.py test/test-skf-update-active-symlink.py test/test-skf-build-change-manifest.py test/test-skf-provenance-gap-dispatch.py test/test-skf-resolve-authoritative-files.py test/test-skf-scan-manifests.py test/test-skf-pair-intersect.py test/test-skf-enumerate-stack-skills.py test/test-skf-compare-file-hashes.py test/test-skf-load-provenance.py test/test-skf-scan-skill-md-structure.py test/test-skf-disqualify-candidates.py test/test-skf-chain-reachability.py test/test-skf-shape-detect.py test/test-skf-detect-docs.py test/test-skf-step-doc-sources.py test/test-skf-step-doc-drift.py test/test-skf-step-hard-gate.py test/test-skf-per-pipeline-thresholds.py test/test-skf-evidence-report-fallback.py test/test-skf-validate-pins.py test/test-skf-auto-shard.py test/test-skf-preapply.py test/test-skf-step-doc-rot.py test/test-skf-campaign-state.py test/test-skf-campaign-pins.py test/test-skf-campaign-deps.py test/test-skf-campaign-report.py test/test-skf-campaign-stepfiles.py test/test-skf-campaign-validate-state.py test/test-skf-campaign-provenance.py test/test-skf-campaign-parse-manifest.py test/test-skf-campaign-render-kickoff.py test/test-skf-campaign-render-batch.py test/test-skf-shard-body.py test/test-skf-render-metadata-stats.py test/test-skf-comention-pairs.py test/test-skf-count-tokens.py test/test-skf-reconcile-coverage.py test/test-skf-verify-provenance-completeness.py test/test-skf-find-cycles.py test/test-skf-validate-feasibility-report.py test/test-skf-rewrite-skill-name.py test/test-skf-verify-no-trace.py test/test-skf-validate-inventory.py test/test-skf-check-metadata-coherence.py test/test-skf-scan-doc-rot.py test/test-skf-aggregate-coherence.py test/test-skf-dir-sizes.py test/test-skf-parse-pipeline.py test/test-skf-validate-rename-name.py test/test-skf-verify-declared-numerator.py test/test-skf-combine-external-scores.py test/test-skf-new-file-diff.py test/test-skf-coverage-tally.py test/test-skf-report-delta.py test/test-skf-verdict-rollup.py -v",
|
|
58
|
+
"test:rehype": "node test/test-rehype-markdown-links.js",
|
|
57
59
|
"test:schemas": "node test/test-agent-schema.js",
|
|
58
60
|
"test:workflow": "node test/test-workflow-state.js",
|
|
61
|
+
"validate:docs-links": "node tools/validate-docs-links.js --strict",
|
|
59
62
|
"validate:refs": "node tools/validate-file-refs.js --strict",
|
|
60
63
|
"validate:schemas": "node test/validate-agent-schema.js",
|
|
61
64
|
"validate:skills": "node tools/validate-skills.js --strict"
|
|
@@ -111,6 +114,7 @@
|
|
|
111
114
|
"prettier-plugin-packagejson": "^2.5.19",
|
|
112
115
|
"semver": "^7.6.3",
|
|
113
116
|
"sharp": "^0.33.5",
|
|
117
|
+
"unist-util-visit": "^5.1.0",
|
|
114
118
|
"wrap-ansi": "^7.0.0",
|
|
115
119
|
"xml2js": "^0.6.2",
|
|
116
120
|
"yaml": "^2.7.0",
|
|
@@ -63,6 +63,8 @@ Output JSON:
|
|
|
63
63
|
|
|
64
64
|
If `restored == true`, also update the in-context copy of `description` to match `guarded_description` so subsequent stages do not work from a stale tool-mutated version. Record `description_guard_restored: true` (with the tool name) in workflow context for the evidence report.
|
|
65
65
|
|
|
66
|
+
**Empty snapshot refusal.** The helper exits 1 and leaves the file untouched when `--captured-description` is empty or whitespace-only. There is nothing to restore from: either `guarded_description` was lost from context between capture and verify, or the field was already empty before the tool ran. Never retry with the empty value — writing it back would blank the field the guard exists to protect. If the compiled description is still available in context (the in-context SKILL.md copy), re-run `verify-restore` with that value. Otherwise record `description_guard_restored: false` and `description_guard_refused: empty-capture` (with the tool name) in workflow context; the calling stage's evidence-report populator renders that as a fired guard (`Restored: false`, the triggering tool, `Original description preserved: false`, `Notes: guard refused — empty captured snapshot (empty-capture)`) rather than as the all-`—` clean-run state, and the frontmatter validator surfaces the empty field through the normal artifact path.
|
|
67
|
+
|
|
66
68
|
## Why Token-Stream Comparison
|
|
67
69
|
|
|
68
70
|
Token-stream comparison is the documented sweet spot between two failure modes:
|
|
@@ -84,6 +84,12 @@
|
|
|
84
84
|
"enum": ["full-library", "specific-modules", "public-api", "component-library", "reference-app", "docs-only"]
|
|
85
85
|
},
|
|
86
86
|
"include": { "type": "array", "items": { "type": "string" } },
|
|
87
|
+
"tier_a_include": {
|
|
88
|
+
"type": "array",
|
|
89
|
+
"description": "Optional narrower tier-A include list. When present it becomes the coverage-denominator basis instead of the coarse scope.include, and it exempts the brief from the denominator deflation guard in skf-test-skill references/source-access-protocol.md — so an empty or blank-item list must not be accepted: it would silence that guard while narrowing nothing.",
|
|
90
|
+
"minItems": 1,
|
|
91
|
+
"items": { "type": "string", "minLength": 1 }
|
|
92
|
+
},
|
|
87
93
|
"exclude": { "type": "array", "items": { "type": "string" } },
|
|
88
94
|
"notes": { "type": "string" },
|
|
89
95
|
"rationale": {
|
|
@@ -32,6 +32,9 @@ Subcommands:
|
|
|
32
32
|
"current_description": "..."}
|
|
33
33
|
If not diverged (including whitespace-only differences), emit
|
|
34
34
|
{"diverged": false, "restored": false, "diff_kind": "none"|"whitespace-only"}
|
|
35
|
+
An empty or whitespace-only --captured-description is refused with
|
|
36
|
+
exit 1 before the file is read: there is nothing to restore from, and
|
|
37
|
+
writing it back would blank the very field this guard protects.
|
|
35
38
|
|
|
36
39
|
Token-stream comparison is the documented sweet spot — catches replaced
|
|
37
40
|
words, truncation, and reintroduced angle-brackets while ignoring cosmetic
|
|
@@ -47,7 +50,8 @@ is auto-resolved:
|
|
|
47
50
|
|
|
48
51
|
Exit codes:
|
|
49
52
|
0 — operation succeeded (including no-divergence verify)
|
|
50
|
-
1 — user error (bad args, file missing,
|
|
53
|
+
1 — user error (bad args, empty captured description, file missing,
|
|
54
|
+
frontmatter unparseable)
|
|
51
55
|
2 — operation failure (restore write failed)
|
|
52
56
|
"""
|
|
53
57
|
|
|
@@ -173,6 +177,11 @@ def restore_description(skill_md: Path, captured: str) -> None:
|
|
|
173
177
|
chooses for each scalar — downstream readers parse YAML, so any valid
|
|
174
178
|
YAML emission is acceptable.
|
|
175
179
|
"""
|
|
180
|
+
if not captured.strip():
|
|
181
|
+
# Defence in depth for library callers: the CLI refuses this earlier,
|
|
182
|
+
# but no code path may ever write an empty description.
|
|
183
|
+
raise ValueError("cannot restore: captured description is empty or whitespace-only")
|
|
184
|
+
|
|
176
185
|
text = skill_md.read_text(encoding="utf-8")
|
|
177
186
|
leading, fm_yaml, body = _split_frontmatter(text)
|
|
178
187
|
if not fm_yaml:
|
|
@@ -238,6 +247,18 @@ def _cmd_capture(args: argparse.Namespace) -> int:
|
|
|
238
247
|
|
|
239
248
|
|
|
240
249
|
def _cmd_verify_restore(args: argparse.Namespace) -> int:
|
|
250
|
+
captured = args.captured_description
|
|
251
|
+
if not captured.strip():
|
|
252
|
+
# Restoring an empty snapshot would blank the field the guard exists
|
|
253
|
+
# to protect. The snapshot was lost from context, or the description
|
|
254
|
+
# was already empty before the tool ran; either way there is nothing
|
|
255
|
+
# to restore, so refuse before touching the file.
|
|
256
|
+
_fail(
|
|
257
|
+
"--captured-description is empty or whitespace-only; refusing to "
|
|
258
|
+
"overwrite the on-disk description with an empty value (the capture "
|
|
259
|
+
"was lost, or the field was already empty before the tool ran)"
|
|
260
|
+
)
|
|
261
|
+
|
|
241
262
|
skill_md = Path(args.skill_md)
|
|
242
263
|
if not skill_md.is_file():
|
|
243
264
|
_fail(f"file not found: {skill_md}")
|
|
@@ -246,7 +267,6 @@ def _cmd_verify_restore(args: argparse.Namespace) -> int:
|
|
|
246
267
|
except ValueError as exc:
|
|
247
268
|
_fail(str(exc))
|
|
248
269
|
|
|
249
|
-
captured = args.captured_description
|
|
250
270
|
diff_kind = classify_divergence(captured, current)
|
|
251
271
|
diverged = is_diverged(diff_kind)
|
|
252
272
|
|
|
@@ -74,6 +74,42 @@ Each list is stably sorted by url. Stored hashes are normalized (leading
|
|
|
74
74
|
`algo:` prefix stripped) before comparison so a bare-hex writer form still
|
|
75
75
|
matches. Exit 0 on any well-formed input (never blocks the informational
|
|
76
76
|
audit); exit 2 on malformed args or JSON.
|
|
77
|
+
|
|
78
|
+
---------------------------------------------------------------------------
|
|
79
|
+
Subcommand: hash-urls
|
|
80
|
+
---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
Compile-time `doc_sources[]` for docs-only skills (create-skill step 5a).
|
|
83
|
+
The detect path above needs a GitHub repository; a docs-only brief has none
|
|
84
|
+
— its `source_repo` is the documentation site and its corpus is the brief's
|
|
85
|
+
`doc_urls`. This subcommand fetches each URL and hashes the raw response
|
|
86
|
+
bytes with the SAME primitive `compare-hashes` uses, so a hash recorded here
|
|
87
|
+
compares byte-for-byte at audit time. (Hashing the markdown a web-fetch tool
|
|
88
|
+
rendered would not: the audit re-fetches the raw bytes.)
|
|
89
|
+
|
|
90
|
+
uv run src/shared/scripts/skf-detect-docs.py hash-urls <source>
|
|
91
|
+
|
|
92
|
+
Input `<source>`:
|
|
93
|
+
- a path to a JSON file that is EITHER an array whose items are URL
|
|
94
|
+
strings or `{url, ...}` objects (the brief's `doc_urls[]` entry shape)
|
|
95
|
+
OR an object with a `doc_urls` array (the brief block itself), OR
|
|
96
|
+
- `-` to read that JSON from stdin.
|
|
97
|
+
|
|
98
|
+
Output (JSON object on stdout):
|
|
99
|
+
{
|
|
100
|
+
"doc_sources": [{"url", "detected_via": "brief_doc_urls",
|
|
101
|
+
"content_hash": "sha256:..."|null, "recorded_at"}],
|
|
102
|
+
"fetch_failed": [{"url", "reason"}],
|
|
103
|
+
"stats": {"total", "hashed", "fetch_failed", "invalid"}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
URLs keep their input order and are deduplicated on first occurrence; an
|
|
107
|
+
entry without a usable url string is dropped and counted under
|
|
108
|
+
`stats.invalid`. A URL that cannot be fetched still gets an entry with
|
|
109
|
+
`content_hash: null` — the audit side files it under `skipped_null_hash`
|
|
110
|
+
instead of reporting drift. `doc_sources` is already in the metadata.json
|
|
111
|
+
schema, and the whole object is valid `compare-hashes` input. Exit 0 on any
|
|
112
|
+
well-formed input; exit 2 on malformed args or JSON.
|
|
77
113
|
"""
|
|
78
114
|
|
|
79
115
|
from __future__ import annotations
|
|
@@ -88,6 +124,7 @@ import subprocess
|
|
|
88
124
|
import sys
|
|
89
125
|
import urllib.error
|
|
90
126
|
import urllib.request
|
|
127
|
+
from datetime import datetime, timezone
|
|
91
128
|
from pathlib import Path
|
|
92
129
|
from typing import Any, Dict, List, Optional, Tuple
|
|
93
130
|
|
|
@@ -252,8 +289,10 @@ def _fetch_and_hash(url: str) -> Optional[str]:
|
|
|
252
289
|
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
253
290
|
except (OSError, IOError):
|
|
254
291
|
return None
|
|
255
|
-
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
256
292
|
try:
|
|
293
|
+
# Request() itself raises ValueError on a scheme-less or relative URL;
|
|
294
|
+
# it must sit inside the guard so a bad entry degrades to None.
|
|
295
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
257
296
|
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
258
297
|
content = resp.read()
|
|
259
298
|
return "sha256:" + hashlib.sha256(content).hexdigest()
|
|
@@ -294,7 +333,14 @@ def _fetch_and_hash_reason(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
|
294
333
|
except OSError as exc:
|
|
295
334
|
return None, f"local read failed: {exc}"
|
|
296
335
|
return "sha256:" + hashlib.sha256(content).hexdigest(), None
|
|
297
|
-
|
|
336
|
+
try:
|
|
337
|
+
# Request() raises ValueError ("unknown url type") on a scheme-less or
|
|
338
|
+
# relative URL — a legal brief entry, since the brief schema only
|
|
339
|
+
# requires a non-empty string. Keep it inside the guard so one bad
|
|
340
|
+
# entry lands in fetch_failed instead of aborting the whole run.
|
|
341
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
342
|
+
except ValueError as exc:
|
|
343
|
+
return None, f"invalid URL: {exc}"
|
|
298
344
|
try:
|
|
299
345
|
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
300
346
|
content = resp.read()
|
|
@@ -601,16 +647,166 @@ def _cmd_compare_hashes(argv: List[str]) -> int:
|
|
|
601
647
|
return 0
|
|
602
648
|
|
|
603
649
|
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
# Subcommand: hash-urls — docs-only doc_sources from the brief's doc_urls
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
# `doc_sources[].detected_via` value for entries that come from the brief's
|
|
655
|
+
# `doc_urls` (and the subpages step 3c fetched from them) rather than from
|
|
656
|
+
# repository detection. Listed in skf-create-skill/assets/skill-sections.md.
|
|
657
|
+
BRIEF_DOC_URLS_DETECTED_VIA = "brief_doc_urls"
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _load_url_list(raw_text: str, source_label: str) -> List[Any]:
|
|
661
|
+
"""Parse a hash-urls input blob into a list of URL entries.
|
|
662
|
+
|
|
663
|
+
Accepts a top-level JSON array whose items are URL strings or objects
|
|
664
|
+
with a `url` key (the brief's `doc_urls[]` entry shape), or a top-level
|
|
665
|
+
object with a `doc_urls` array (the brief block itself). An object without
|
|
666
|
+
`doc_urls` yields an empty list (nothing to hash).
|
|
667
|
+
|
|
668
|
+
Raises ValueError on malformed JSON or an unexpected top-level shape.
|
|
669
|
+
"""
|
|
670
|
+
try:
|
|
671
|
+
data = json.loads(raw_text)
|
|
672
|
+
except json.JSONDecodeError as exc:
|
|
673
|
+
raise ValueError(f"malformed JSON in {source_label}: {exc}") from exc
|
|
674
|
+
if isinstance(data, list):
|
|
675
|
+
return data
|
|
676
|
+
if isinstance(data, dict):
|
|
677
|
+
entries = data.get("doc_urls")
|
|
678
|
+
if entries is None:
|
|
679
|
+
return []
|
|
680
|
+
if not isinstance(entries, list):
|
|
681
|
+
raise ValueError(f"`doc_urls` in {source_label} is not an array")
|
|
682
|
+
return entries
|
|
683
|
+
raise ValueError(
|
|
684
|
+
f"{source_label} must be a JSON array or an object with a "
|
|
685
|
+
f"`doc_urls` array; got {type(data).__name__}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def hash_urls(entries: List[Any], recorded_at: Optional[str] = None) -> Dict[str, Any]:
|
|
690
|
+
"""Fetch and hash each URL into a compile-time `doc_sources[]` entry.
|
|
691
|
+
|
|
692
|
+
Byte-symmetric with `compare_doc_hashes`: both go through
|
|
693
|
+
`_fetch_and_hash_reason`, so a hash recorded here reads as `unchanged` at
|
|
694
|
+
audit time for unchanged bytes. URLs keep their input order (brief order,
|
|
695
|
+
then discovered subpages) and are deduplicated on first occurrence. A URL
|
|
696
|
+
that cannot be fetched still gets an entry with `content_hash: None`, which
|
|
697
|
+
the audit side skips rather than reporting as drift.
|
|
698
|
+
"""
|
|
699
|
+
stamp = recorded_at or datetime.now(timezone.utc).isoformat()
|
|
700
|
+
doc_sources: List[Dict[str, Any]] = []
|
|
701
|
+
fetch_failed: List[Dict[str, Any]] = []
|
|
702
|
+
seen: set = set()
|
|
703
|
+
invalid = 0
|
|
704
|
+
for entry in entries:
|
|
705
|
+
url = entry.get("url") if isinstance(entry, dict) else entry
|
|
706
|
+
if not isinstance(url, str) or not url.strip():
|
|
707
|
+
invalid += 1
|
|
708
|
+
continue
|
|
709
|
+
if url in seen:
|
|
710
|
+
continue
|
|
711
|
+
seen.add(url)
|
|
712
|
+
content_hash, reason = _fetch_and_hash_reason(url)
|
|
713
|
+
doc_sources.append({
|
|
714
|
+
"url": url,
|
|
715
|
+
"detected_via": BRIEF_DOC_URLS_DETECTED_VIA,
|
|
716
|
+
"content_hash": content_hash,
|
|
717
|
+
"recorded_at": stamp,
|
|
718
|
+
})
|
|
719
|
+
if content_hash is None:
|
|
720
|
+
fetch_failed.append({"url": url, "reason": reason})
|
|
721
|
+
return {
|
|
722
|
+
"doc_sources": doc_sources,
|
|
723
|
+
"fetch_failed": fetch_failed,
|
|
724
|
+
"stats": {
|
|
725
|
+
"total": len(doc_sources),
|
|
726
|
+
"hashed": len(doc_sources) - len(fetch_failed),
|
|
727
|
+
"fetch_failed": len(fetch_failed),
|
|
728
|
+
"invalid": invalid,
|
|
729
|
+
},
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _cmd_hash_urls(argv: List[str]) -> int:
|
|
734
|
+
parser = argparse.ArgumentParser(
|
|
735
|
+
prog="skf-detect-docs.py hash-urls",
|
|
736
|
+
description=(
|
|
737
|
+
"Fetch each documentation URL, hash the response bytes, and emit "
|
|
738
|
+
"compile-time doc_sources entries (detected_via: brief_doc_urls) "
|
|
739
|
+
"for a docs-only skill."
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
parser.add_argument(
|
|
743
|
+
"source",
|
|
744
|
+
help=(
|
|
745
|
+
"path to a JSON file (array of URL strings or {url, ...} objects, "
|
|
746
|
+
"or an object with a doc_urls array such as the brief's block), "
|
|
747
|
+
"or - to read that JSON from stdin"
|
|
748
|
+
),
|
|
749
|
+
)
|
|
750
|
+
args = parser.parse_args(argv)
|
|
751
|
+
|
|
752
|
+
if args.source == "-":
|
|
753
|
+
raw = sys.stdin.read()
|
|
754
|
+
label = "<stdin>"
|
|
755
|
+
else:
|
|
756
|
+
path = Path(args.source)
|
|
757
|
+
try:
|
|
758
|
+
raw = path.read_text(encoding="utf-8")
|
|
759
|
+
except OSError as exc:
|
|
760
|
+
json.dump({"error": f"cannot read {path}: {exc}", "code": "READ_ERROR"}, sys.stderr)
|
|
761
|
+
sys.stderr.write("\n")
|
|
762
|
+
return 2
|
|
763
|
+
label = str(path)
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
entries = _load_url_list(raw, label)
|
|
767
|
+
except ValueError as exc:
|
|
768
|
+
json.dump({"error": str(exc), "code": "INVALID_JSON"}, sys.stderr)
|
|
769
|
+
sys.stderr.write("\n")
|
|
770
|
+
return 2
|
|
771
|
+
|
|
772
|
+
result = hash_urls(entries)
|
|
773
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
774
|
+
sys.stdout.write("\n")
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
|
|
604
778
|
# ---------------------------------------------------------------------------
|
|
605
779
|
# CLI entry point
|
|
606
780
|
# ---------------------------------------------------------------------------
|
|
607
781
|
|
|
782
|
+
def _force_utf8(*streams) -> None:
|
|
783
|
+
"""Reconfigure JSON-carrying streams to UTF-8 (issue #465).
|
|
784
|
+
|
|
785
|
+
A default Windows console decodes stdio as cp1252, which cannot carry
|
|
786
|
+
non-ASCII JSON (raw UTF-8 input on stdin for `compare-hashes -` and
|
|
787
|
+
`hash-urls -`). Preserves each stream's existing error handler —
|
|
788
|
+
reconfigure(encoding=...) alone would reset it to 'strict', downgrading
|
|
789
|
+
e.g. an already-UTF-8 stderr on Linux. For stdin this must run before the
|
|
790
|
+
first read. Skips in-process test doubles without reconfigure().
|
|
791
|
+
"""
|
|
792
|
+
for stream in streams:
|
|
793
|
+
if hasattr(stream, "reconfigure"):
|
|
794
|
+
errors = getattr(stream, "errors", None)
|
|
795
|
+
if errors is None:
|
|
796
|
+
stream.reconfigure(encoding="utf-8")
|
|
797
|
+
else:
|
|
798
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
799
|
+
|
|
800
|
+
|
|
608
801
|
def main() -> int:
|
|
802
|
+
_force_utf8(sys.stdin, sys.stdout)
|
|
609
803
|
# Additive subcommand: route `compare-hashes ...` to the doc-drift path
|
|
610
804
|
# before the existing detect parser runs. The default (no subcommand)
|
|
611
805
|
# invocation `--repo-url <url> ...` is unchanged.
|
|
612
806
|
if len(sys.argv) > 1 and sys.argv[1] == "compare-hashes":
|
|
613
807
|
return _cmd_compare_hashes(sys.argv[2:])
|
|
808
|
+
if len(sys.argv) > 1 and sys.argv[1] == "hash-urls":
|
|
809
|
+
return _cmd_hash_urls(sys.argv[2:])
|
|
614
810
|
|
|
615
811
|
parser = argparse.ArgumentParser(
|
|
616
812
|
description="Detect documentation URLs for a GitHub repository.",
|
|
@@ -378,7 +378,27 @@ def _parse_argv(argv: list[str]) -> dict:
|
|
|
378
378
|
return payload
|
|
379
379
|
|
|
380
380
|
|
|
381
|
+
def _force_utf8(*streams) -> None:
|
|
382
|
+
"""Reconfigure JSON-carrying streams to UTF-8 (issue #465).
|
|
383
|
+
|
|
384
|
+
A default Windows console decodes stdio as cp1252, which cannot carry
|
|
385
|
+
non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
|
|
386
|
+
each stream's existing error handler — reconfigure(encoding=...) alone
|
|
387
|
+
would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
|
|
388
|
+
Linux. For stdin this must run before the first read. Skips in-process
|
|
389
|
+
test doubles without reconfigure().
|
|
390
|
+
"""
|
|
391
|
+
for stream in streams:
|
|
392
|
+
if hasattr(stream, "reconfigure"):
|
|
393
|
+
errors = getattr(stream, "errors", None)
|
|
394
|
+
if errors is None:
|
|
395
|
+
stream.reconfigure(encoding="utf-8")
|
|
396
|
+
else:
|
|
397
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
398
|
+
|
|
399
|
+
|
|
381
400
|
def main(argv: list[str]) -> int:
|
|
401
|
+
_force_utf8(sys.stdin, sys.stdout)
|
|
382
402
|
payload = _parse_argv(argv)
|
|
383
403
|
result = detect(payload)
|
|
384
404
|
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
@@ -89,6 +89,7 @@ DETECT_OUTPUT_SCHEMA (v1):
|
|
|
89
89
|
"previous_ccc_indexed_path": str|null,
|
|
90
90
|
"previous_ccc_last_indexed": str|null,
|
|
91
91
|
"previous_ccc_staleness_threshold_hours": int|null,
|
|
92
|
+
"previous_ccc_file_count": int|null,
|
|
92
93
|
# Deterministic CCC-index freshness verdict — computed against
|
|
93
94
|
# --project-root and datetime.now(UTC) captured at detect time, so the
|
|
94
95
|
# ccc-index.md step branches on a boolean instead of doing timestamp math.
|
|
@@ -142,6 +143,29 @@ def _ok(payload: dict) -> None:
|
|
|
142
143
|
print(json.dumps(payload))
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
def _resolve_outside_cwd(command: str) -> str | None:
|
|
147
|
+
"""shutil.which with a CWD-shim guard. Returns the resolved path or None.
|
|
148
|
+
|
|
149
|
+
shutil.which on Windows searches the current directory ahead of PATH,
|
|
150
|
+
and CWD here is the repo under analysis — a bare-name lookup resolving
|
|
151
|
+
into CWD would execute a repo-planted shim (e.g. ast-grep.cmd). Such a
|
|
152
|
+
resolution is treated as not-found. Explicit paths supplied by callers
|
|
153
|
+
(containing a separator) are honored as-is. Keep identical to the
|
|
154
|
+
sibling guard in skf-qmd-classify-collections.py.
|
|
155
|
+
"""
|
|
156
|
+
resolved = shutil.which(command)
|
|
157
|
+
if resolved is None:
|
|
158
|
+
return None
|
|
159
|
+
if os.sep in command or (os.altsep and os.altsep in command):
|
|
160
|
+
return resolved
|
|
161
|
+
resolved_dir = os.path.dirname(resolved)
|
|
162
|
+
if resolved_dir:
|
|
163
|
+
cwd = os.path.normcase(os.path.abspath(os.getcwd()))
|
|
164
|
+
if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
|
|
165
|
+
return None
|
|
166
|
+
return resolved
|
|
167
|
+
|
|
168
|
+
|
|
145
169
|
def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, str]:
|
|
146
170
|
"""Run a subprocess. Return (returncode, stdout, stderr). Never raises.
|
|
147
171
|
|
|
@@ -149,6 +173,13 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
|
|
|
149
173
|
CalledProcessError) as a failed probe — returns rc=127 and an empty
|
|
150
174
|
stdout/stderr. Tool detection should never crash the workflow.
|
|
151
175
|
|
|
176
|
+
cmd[0] is resolved through shutil.which() before spawning (with the
|
|
177
|
+
CWD-shim guard in _resolve_outside_cwd): Windows CreateProcess only
|
|
178
|
+
ever appends .exe to a bare name, so npm-installed shims
|
|
179
|
+
(ast-grep.CMD, qmd.CMD) would raise FileNotFoundError and read as
|
|
180
|
+
"tool absent". An unresolvable command returns rc=127 without
|
|
181
|
+
spawning anything.
|
|
182
|
+
|
|
152
183
|
The child is wrapped in the OS `timeout(1)` utility (when available) so a
|
|
153
184
|
daemon-backed probe blocked in an uninterruptible syscall (e.g.
|
|
154
185
|
`qmd status` waiting on its daemon socket) is reaped at the OS level —
|
|
@@ -158,6 +189,10 @@ def _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, st
|
|
|
158
189
|
`start_new_session=True` isolates the child's process group; the
|
|
159
190
|
Python-level timeout is a secondary net set slightly above the OS one.
|
|
160
191
|
"""
|
|
192
|
+
resolved = _resolve_outside_cwd(cmd[0])
|
|
193
|
+
if resolved is None:
|
|
194
|
+
return 127, "", ""
|
|
195
|
+
cmd = [resolved, *cmd[1:]]
|
|
161
196
|
if _TIMEOUT_BIN:
|
|
162
197
|
run_cmd = [_TIMEOUT_BIN, "--kill-after=2", str(timeout), *cmd]
|
|
163
198
|
py_timeout = timeout + 5
|
|
@@ -335,6 +370,7 @@ def read_prior_state(prior_state_path) -> dict:
|
|
|
335
370
|
"previous_ccc_indexed_path": None,
|
|
336
371
|
"previous_ccc_last_indexed": None,
|
|
337
372
|
"previous_ccc_staleness_threshold_hours": None,
|
|
373
|
+
"previous_ccc_file_count": None,
|
|
338
374
|
}
|
|
339
375
|
if not prior_state_path:
|
|
340
376
|
return empty
|
|
@@ -359,6 +395,10 @@ def read_prior_state(prior_state_path) -> dict:
|
|
|
359
395
|
"previous_ccc_indexed_path": ccc_index.get("indexed_path"),
|
|
360
396
|
"previous_ccc_last_indexed": ccc_index.get("last_indexed"),
|
|
361
397
|
"previous_ccc_staleness_threshold_hours": ccc_index.get("staleness_threshold_hours"),
|
|
398
|
+
# Surfaced so ccc-index.md's fresh-index branch can carry the count
|
|
399
|
+
# forward instead of leaving `{ccc_file_count}` unbound (or nulling a
|
|
400
|
+
# value the index still has) when nothing re-indexes this run.
|
|
401
|
+
"previous_ccc_file_count": ccc_index.get("file_count"),
|
|
362
402
|
}
|
|
363
403
|
|
|
364
404
|
|