@opengsd/gsd-path 1.0.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/AGENTS.md +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,1517 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Classify or initialize a working tree.
|
|
4
|
+
|
|
5
|
+
The router, define, and inspect phases call this instead of inferring
|
|
6
|
+
brownfield vs greenfield from a directory listing. classify is read-only.
|
|
7
|
+
initialize creates .project/STATE.md after classifying a new brownfield or
|
|
8
|
+
greenfield project.
|
|
9
|
+
|
|
10
|
+
Verdicts:
|
|
11
|
+
|
|
12
|
+
- owned — `.project/` is a real directory with a regular `STATE.md` and no
|
|
13
|
+
unsafe project paths; route by that file, not detection
|
|
14
|
+
- orphan — `.project/` is unsafe, or it contains an entry without a safe
|
|
15
|
+
regular `STATE.md`; the initializer's regular temporary state file is
|
|
16
|
+
recoverable and does not make the project orphaned
|
|
17
|
+
- brownfield — no owned state, `.project/` empty or absent, and at least one
|
|
18
|
+
in-scope signal (package/build manifest, source file, tracked signal in
|
|
19
|
+
git, or substantive system documentation)
|
|
20
|
+
- greenfield — no owned state, `.project/` empty or absent, and no signal
|
|
21
|
+
|
|
22
|
+
Ignored path components: `.git`, `node_modules`, vendored/generated trees,
|
|
23
|
+
build output, and managed GSD Path installation artifacts. `.project/` is
|
|
24
|
+
never a brownfield signal; it only decides owned vs orphan. A title-only root
|
|
25
|
+
README, with or without a Markdown suffix, is not substantive documentation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import stat
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import date
|
|
38
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
39
|
+
from typing import Iterable, NamedTuple, Optional, Sequence
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import fcntl
|
|
43
|
+
|
|
44
|
+
def lock_exclusive(fd: int) -> None:
|
|
45
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
46
|
+
|
|
47
|
+
except ImportError: # Windows does not provide POSIX file locks.
|
|
48
|
+
import msvcrt
|
|
49
|
+
|
|
50
|
+
def lock_exclusive(fd: int) -> None:
|
|
51
|
+
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
IGNORE_DIRS = {
|
|
55
|
+
".git",
|
|
56
|
+
"node_modules",
|
|
57
|
+
"dist",
|
|
58
|
+
"build",
|
|
59
|
+
"out",
|
|
60
|
+
"target",
|
|
61
|
+
"vendor",
|
|
62
|
+
".venv",
|
|
63
|
+
"venv",
|
|
64
|
+
"__pycache__",
|
|
65
|
+
".next",
|
|
66
|
+
"coverage",
|
|
67
|
+
".turbo",
|
|
68
|
+
".cache",
|
|
69
|
+
"generated",
|
|
70
|
+
".tox",
|
|
71
|
+
".mypy_cache",
|
|
72
|
+
".pytest_cache",
|
|
73
|
+
"bower_components",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
MANIFEST_NAMES = {
|
|
77
|
+
"package.json",
|
|
78
|
+
"pnpm-workspace.yaml",
|
|
79
|
+
"lerna.json",
|
|
80
|
+
"pyproject.toml",
|
|
81
|
+
"setup.py",
|
|
82
|
+
"setup.cfg",
|
|
83
|
+
"requirements.txt",
|
|
84
|
+
"Pipfile",
|
|
85
|
+
"Cargo.toml",
|
|
86
|
+
"go.mod",
|
|
87
|
+
"Gemfile",
|
|
88
|
+
"pom.xml",
|
|
89
|
+
"build.gradle",
|
|
90
|
+
"build.gradle.kts",
|
|
91
|
+
"settings.gradle",
|
|
92
|
+
"settings.gradle.kts",
|
|
93
|
+
"CMakeLists.txt",
|
|
94
|
+
"Dockerfile",
|
|
95
|
+
"Makefile",
|
|
96
|
+
"makefile",
|
|
97
|
+
"meson.build",
|
|
98
|
+
"composer.json",
|
|
99
|
+
"mix.exs",
|
|
100
|
+
"Package.swift",
|
|
101
|
+
"deno.json",
|
|
102
|
+
"deno.jsonc",
|
|
103
|
+
"flake.nix",
|
|
104
|
+
"WORKSPACE",
|
|
105
|
+
"WORKSPACE.bazel",
|
|
106
|
+
"MODULE.bazel",
|
|
107
|
+
"pubspec.yaml",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
MANIFEST_SUFFIXES = {".csproj", ".fsproj", ".vbproj", ".sln"}
|
|
111
|
+
|
|
112
|
+
SOURCE_SUFFIXES = {
|
|
113
|
+
".py",
|
|
114
|
+
".pyi",
|
|
115
|
+
".ts",
|
|
116
|
+
".tsx",
|
|
117
|
+
".js",
|
|
118
|
+
".jsx",
|
|
119
|
+
".mjs",
|
|
120
|
+
".cjs",
|
|
121
|
+
".go",
|
|
122
|
+
".rs",
|
|
123
|
+
".java",
|
|
124
|
+
".kt",
|
|
125
|
+
".kts",
|
|
126
|
+
".scala",
|
|
127
|
+
".rb",
|
|
128
|
+
".php",
|
|
129
|
+
".cs",
|
|
130
|
+
".fs",
|
|
131
|
+
".swift",
|
|
132
|
+
".m",
|
|
133
|
+
".mm",
|
|
134
|
+
".c",
|
|
135
|
+
".cc",
|
|
136
|
+
".cpp",
|
|
137
|
+
".cxx",
|
|
138
|
+
".h",
|
|
139
|
+
".hpp",
|
|
140
|
+
".hxx",
|
|
141
|
+
".lua",
|
|
142
|
+
".ex",
|
|
143
|
+
".exs",
|
|
144
|
+
".erl",
|
|
145
|
+
".hs",
|
|
146
|
+
".ml",
|
|
147
|
+
".mli",
|
|
148
|
+
".clj",
|
|
149
|
+
".cljs",
|
|
150
|
+
".dart",
|
|
151
|
+
".html",
|
|
152
|
+
".css",
|
|
153
|
+
".sh",
|
|
154
|
+
".tf",
|
|
155
|
+
".zig",
|
|
156
|
+
".nim",
|
|
157
|
+
".vue",
|
|
158
|
+
".svelte",
|
|
159
|
+
".r",
|
|
160
|
+
".jl",
|
|
161
|
+
".sql",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
MARKDOWN_SUFFIXES = {".md", ".mdx", ".markdown"}
|
|
165
|
+
|
|
166
|
+
GREENFIELD_DOC_STEMS = {
|
|
167
|
+
"license",
|
|
168
|
+
"licence",
|
|
169
|
+
"copying",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
MANAGED_EXACT_FILES = {
|
|
173
|
+
"AGENTS.md",
|
|
174
|
+
"WORKFLOW.md",
|
|
175
|
+
".claude/CLAUDE.md",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
VERIFIED_INSTALLER_SKILL_ROOTS = {
|
|
179
|
+
(".agents", "skills"),
|
|
180
|
+
(".claude", "skills"),
|
|
181
|
+
(".cursor", "skills"),
|
|
182
|
+
(".github", "skills"),
|
|
183
|
+
(".grok", "skills"),
|
|
184
|
+
(".kiro", "skills"),
|
|
185
|
+
(".kimi-code", "skills"),
|
|
186
|
+
(".opencode", "skills"),
|
|
187
|
+
(".qwen", "skills"),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
ATX_TITLE = re.compile(r" {0,3}#{1,6}(?:[ \t]+.*)?")
|
|
191
|
+
SETEXT_UNDERLINE = re.compile(r" {0,3}(?:=+|-+)[ \t]*")
|
|
192
|
+
SETEXT_BLOCK_START = re.compile(
|
|
193
|
+
r" {0,3}(?:>|[-+*](?:[ \t]+|$)|\d{1,9}[.)](?:[ \t]+|$)|"
|
|
194
|
+
r"`{3,}|~{3,}|\[[^]]+\]:)"
|
|
195
|
+
)
|
|
196
|
+
HTML_BLOCK_START = re.compile(
|
|
197
|
+
r" {0,3}(?:"
|
|
198
|
+
r"<(?:script|pre|style|textarea)(?:[ \t]+|>|$)"
|
|
199
|
+
r"|<!--|<\?|<![A-Z]|<!\[CDATA\["
|
|
200
|
+
r"|</?(?:address|article|aside|base|basefont|blockquote|body|caption|center|"
|
|
201
|
+
r"col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|"
|
|
202
|
+
r"footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|"
|
|
203
|
+
r"link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|"
|
|
204
|
+
r"section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)"
|
|
205
|
+
r"(?:[ \t]+|/?>|$)"
|
|
206
|
+
r"|</?[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[^>]*)?/?>[ \t]*$)",
|
|
207
|
+
re.IGNORECASE,
|
|
208
|
+
)
|
|
209
|
+
REPARSE_NAME_SURROGATE = 0x20000000
|
|
210
|
+
ANCHORED_EVIDENCE_SUPPORTED = (
|
|
211
|
+
hasattr(os, "O_DIRECTORY")
|
|
212
|
+
and hasattr(os, "O_NOFOLLOW")
|
|
213
|
+
and os.open in os.supports_dir_fd
|
|
214
|
+
and os.stat in os.supports_dir_fd
|
|
215
|
+
and os.stat in os.supports_follow_symlinks
|
|
216
|
+
)
|
|
217
|
+
LISTDIR_DIR_FD_SUPPORTED = os.listdir in getattr(os, "supports_fd", ())
|
|
218
|
+
ANCHORED_STATE_CREATE_SUPPORTED = (
|
|
219
|
+
ANCHORED_EVIDENCE_SUPPORTED
|
|
220
|
+
and LISTDIR_DIR_FD_SUPPORTED
|
|
221
|
+
and os.mkdir in getattr(os, "supports_dir_fd", ())
|
|
222
|
+
and os.unlink in getattr(os, "supports_dir_fd", ())
|
|
223
|
+
and os.link in getattr(os, "supports_dir_fd", ())
|
|
224
|
+
and os.link in getattr(os, "supports_follow_symlinks", ())
|
|
225
|
+
)
|
|
226
|
+
GIT_OVERRIDE_VARS = (
|
|
227
|
+
"GIT_DIR",
|
|
228
|
+
"GIT_WORK_TREE",
|
|
229
|
+
"GIT_INDEX_FILE",
|
|
230
|
+
"GIT_OBJECT_DIRECTORY",
|
|
231
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
232
|
+
"GIT_COMMON_DIR",
|
|
233
|
+
"GIT_PREFIX",
|
|
234
|
+
"GIT_NAMESPACE",
|
|
235
|
+
)
|
|
236
|
+
FRONTMATTER_FIELD_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$")
|
|
237
|
+
STATE_TEMP_NAME = ".STATE.md.gsd-path-tmp"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class DetectError(RuntimeError):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class GitIndexEntry(NamedTuple):
|
|
245
|
+
path: str
|
|
246
|
+
mode: int
|
|
247
|
+
oid: str
|
|
248
|
+
stage: int
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def is_link_like_status(status: os.stat_result) -> bool:
|
|
252
|
+
if stat.S_ISLNK(status.st_mode):
|
|
253
|
+
return True
|
|
254
|
+
if getattr(status, "st_reparse_tag", 0) & REPARSE_NAME_SURROGATE:
|
|
255
|
+
return True
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def is_link_like(path: Path, status: os.stat_result) -> bool:
|
|
260
|
+
if is_link_like_status(status):
|
|
261
|
+
return True
|
|
262
|
+
isjunction = getattr(os.path, "isjunction", None)
|
|
263
|
+
if os.name != "nt" or isjunction is None:
|
|
264
|
+
return False
|
|
265
|
+
try:
|
|
266
|
+
return bool(isjunction(path))
|
|
267
|
+
except OSError as error:
|
|
268
|
+
raise DetectError(f"cannot inspect link-like evidence: {path}: {error}") from error
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def posix_relative(path: Path, root: Path) -> str:
|
|
272
|
+
return path.relative_to(root).as_posix()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def is_ignored(relative: str) -> bool:
|
|
276
|
+
return any(part in IGNORE_DIRS for part in PurePosixPath(relative).parts)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def is_skill_bundle_name(name: str) -> bool:
|
|
280
|
+
folded = name.casefold()
|
|
281
|
+
return folded in {"ogsd", "gsd-path"} or folded.startswith(("ogsd-", "gsd-path-"))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def is_verified_installer_bundle(parts: tuple[str, ...]) -> bool:
|
|
285
|
+
parent = tuple(part.casefold() for part in parts[:-1])
|
|
286
|
+
return parent in VERIFIED_INSTALLER_SKILL_ROOTS
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def has_safe_bundle_path(root: Path, parts: tuple[str, ...]) -> bool:
|
|
290
|
+
current = root
|
|
291
|
+
for part in parts:
|
|
292
|
+
current /= part
|
|
293
|
+
status = lstat_evidence(current, missing_ok=True)
|
|
294
|
+
if (
|
|
295
|
+
status is None
|
|
296
|
+
or is_link_like(current, status)
|
|
297
|
+
or not stat.S_ISDIR(status.st_mode)
|
|
298
|
+
):
|
|
299
|
+
return False
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def is_verified_skill_bundle(relative: str, root: Path) -> bool:
|
|
304
|
+
parts = PurePosixPath(relative).parts
|
|
305
|
+
if not parts:
|
|
306
|
+
return False
|
|
307
|
+
name = parts[-1]
|
|
308
|
+
if not (is_skill_bundle_name(name) or (
|
|
309
|
+
name.casefold() == "path" and is_verified_installer_bundle(parts)
|
|
310
|
+
)):
|
|
311
|
+
return False
|
|
312
|
+
if not has_safe_bundle_path(root, parts):
|
|
313
|
+
return False
|
|
314
|
+
if is_verified_installer_bundle(parts):
|
|
315
|
+
return True
|
|
316
|
+
skill = root.joinpath(*parts) / "SKILL.md"
|
|
317
|
+
status = lstat_evidence(skill, missing_ok=True)
|
|
318
|
+
return status is not None and stat.S_ISREG(status.st_mode)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def is_managed_pipeline_directory(relative: str, root: Path) -> bool:
|
|
322
|
+
return is_verified_skill_bundle(relative, root)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def is_verified_skill_bundle_at(relative: str, directory_fd: int) -> bool:
|
|
326
|
+
parts = PurePosixPath(relative).parts
|
|
327
|
+
if not parts:
|
|
328
|
+
return False
|
|
329
|
+
if not (is_skill_bundle_name(parts[-1]) or (
|
|
330
|
+
parts[-1].casefold() == "path" and is_verified_installer_bundle(parts)
|
|
331
|
+
)):
|
|
332
|
+
return False
|
|
333
|
+
if is_verified_installer_bundle(parts):
|
|
334
|
+
return True
|
|
335
|
+
try:
|
|
336
|
+
status = os.stat(
|
|
337
|
+
"SKILL.md",
|
|
338
|
+
dir_fd=directory_fd,
|
|
339
|
+
follow_symlinks=False,
|
|
340
|
+
)
|
|
341
|
+
except FileNotFoundError:
|
|
342
|
+
return False
|
|
343
|
+
except OSError as error:
|
|
344
|
+
raise DetectError(
|
|
345
|
+
f"cannot inspect managed skill bundle: {relative}: {error}"
|
|
346
|
+
) from error
|
|
347
|
+
return not is_link_like_status(status) and stat.S_ISREG(status.st_mode)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def is_fixed_managed_pipeline_artifact(relative: str) -> bool:
|
|
351
|
+
if relative in MANAGED_EXACT_FILES:
|
|
352
|
+
return True
|
|
353
|
+
parts = PurePosixPath(relative).parts
|
|
354
|
+
if (
|
|
355
|
+
len(parts) >= 2
|
|
356
|
+
and parts[-2].casefold() == "agents"
|
|
357
|
+
and parts[-1].casefold() == "gsd-path.md"
|
|
358
|
+
):
|
|
359
|
+
return True
|
|
360
|
+
if (
|
|
361
|
+
(len(parts) == 2 or (len(parts) == 3 and parts[1] == "runtime"))
|
|
362
|
+
and parts[0] == ".gsd-path"
|
|
363
|
+
and PurePosixPath(parts[-1]).suffix.casefold() == ".py"
|
|
364
|
+
):
|
|
365
|
+
return True
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def is_managed_pipeline_artifact(relative: str, root: Path) -> bool:
|
|
370
|
+
if is_fixed_managed_pipeline_artifact(relative):
|
|
371
|
+
return True
|
|
372
|
+
parts = PurePosixPath(relative).parts
|
|
373
|
+
return any(
|
|
374
|
+
is_verified_skill_bundle("/".join(parts[:end]), root)
|
|
375
|
+
for end in range(1, len(parts))
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def git_environment() -> dict[str, str]:
|
|
380
|
+
env = os.environ.copy()
|
|
381
|
+
for key in GIT_OVERRIDE_VARS:
|
|
382
|
+
env.pop(key, None)
|
|
383
|
+
env["GIT_NO_LAZY_FETCH"] = "1"
|
|
384
|
+
env["GIT_NO_REPLACE_OBJECTS"] = "1"
|
|
385
|
+
return env
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def lstat_evidence(path: Path, *, missing_ok: bool) -> Optional[os.stat_result]:
|
|
389
|
+
try:
|
|
390
|
+
return os.lstat(path)
|
|
391
|
+
except FileNotFoundError as error:
|
|
392
|
+
if missing_ok:
|
|
393
|
+
return None
|
|
394
|
+
raise DetectError(f"filesystem evidence disappeared: {path}") from error
|
|
395
|
+
except OSError as error:
|
|
396
|
+
raise DetectError(
|
|
397
|
+
f"cannot inspect filesystem evidence: {path}: {error}"
|
|
398
|
+
) from error
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def read_regular_evidence(
|
|
402
|
+
path: Path,
|
|
403
|
+
root: Path,
|
|
404
|
+
*,
|
|
405
|
+
missing_ok: bool,
|
|
406
|
+
evidence_name: str,
|
|
407
|
+
) -> Optional[str]:
|
|
408
|
+
try:
|
|
409
|
+
relative = path.relative_to(root)
|
|
410
|
+
except ValueError as error:
|
|
411
|
+
raise DetectError(
|
|
412
|
+
f"{evidence_name} evidence escapes repo: {path}"
|
|
413
|
+
) from error
|
|
414
|
+
if not relative.parts:
|
|
415
|
+
raise DetectError(f"{evidence_name} evidence path is empty: {path}")
|
|
416
|
+
if ANCHORED_EVIDENCE_SUPPORTED:
|
|
417
|
+
return read_anchored_evidence(
|
|
418
|
+
relative,
|
|
419
|
+
root,
|
|
420
|
+
missing_ok=missing_ok,
|
|
421
|
+
evidence_name=evidence_name,
|
|
422
|
+
)
|
|
423
|
+
return read_lstat_open_evidence(
|
|
424
|
+
relative,
|
|
425
|
+
root,
|
|
426
|
+
missing_ok=missing_ok,
|
|
427
|
+
evidence_name=evidence_name,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def directory_flags() -> int:
|
|
432
|
+
return (
|
|
433
|
+
os.O_RDONLY
|
|
434
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
435
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
436
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def file_flags() -> int:
|
|
441
|
+
return (
|
|
442
|
+
os.O_RDONLY
|
|
443
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
444
|
+
| getattr(os, "O_BINARY", 0)
|
|
445
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def read_anchored_evidence(
|
|
450
|
+
relative: Path,
|
|
451
|
+
root: Path,
|
|
452
|
+
*,
|
|
453
|
+
missing_ok: bool,
|
|
454
|
+
evidence_name: str,
|
|
455
|
+
) -> Optional[str]:
|
|
456
|
+
directory_open = directory_flags()
|
|
457
|
+
file_open = file_flags()
|
|
458
|
+
descriptors = []
|
|
459
|
+
try:
|
|
460
|
+
descriptors.append(os.open(root, directory_open))
|
|
461
|
+
if not stat.S_ISDIR(os.fstat(descriptors[-1]).st_mode):
|
|
462
|
+
raise DetectError(f"repo evidence is not a directory: {root}")
|
|
463
|
+
for index, part in enumerate(relative.parts):
|
|
464
|
+
current = root.joinpath(*relative.parts[: index + 1])
|
|
465
|
+
expected = os.stat(
|
|
466
|
+
part,
|
|
467
|
+
dir_fd=descriptors[-1],
|
|
468
|
+
follow_symlinks=False,
|
|
469
|
+
)
|
|
470
|
+
if is_link_like(current, expected):
|
|
471
|
+
raise DetectError(
|
|
472
|
+
f"link-like {evidence_name} evidence is not allowed: {current}"
|
|
473
|
+
)
|
|
474
|
+
final = index == len(relative.parts) - 1
|
|
475
|
+
if not final and not stat.S_ISDIR(expected.st_mode):
|
|
476
|
+
raise DetectError(
|
|
477
|
+
f"{evidence_name} evidence parent is not a directory: {current}"
|
|
478
|
+
)
|
|
479
|
+
if final and not stat.S_ISREG(expected.st_mode):
|
|
480
|
+
raise DetectError(
|
|
481
|
+
f"{evidence_name} evidence is not a regular file: {current}"
|
|
482
|
+
)
|
|
483
|
+
flags = file_open if final else directory_open
|
|
484
|
+
descriptor = os.open(part, flags, dir_fd=descriptors[-1])
|
|
485
|
+
descriptors.append(descriptor)
|
|
486
|
+
actual = os.fstat(descriptor)
|
|
487
|
+
expected_kind = stat.S_ISREG if final else stat.S_ISDIR
|
|
488
|
+
if not expected_kind(actual.st_mode) or not os.path.samestat(
|
|
489
|
+
expected,
|
|
490
|
+
actual,
|
|
491
|
+
):
|
|
492
|
+
raise DetectError(
|
|
493
|
+
f"{evidence_name} evidence changed while reading: {current}"
|
|
494
|
+
)
|
|
495
|
+
with os.fdopen(descriptors.pop(), "rb") as stream:
|
|
496
|
+
data = stream.read()
|
|
497
|
+
except FileNotFoundError as error:
|
|
498
|
+
if missing_ok:
|
|
499
|
+
return None
|
|
500
|
+
raise DetectError(f"filesystem evidence disappeared: {root / relative}") from error
|
|
501
|
+
except DetectError:
|
|
502
|
+
raise
|
|
503
|
+
except OSError as error:
|
|
504
|
+
raise DetectError(
|
|
505
|
+
f"cannot read {evidence_name} evidence: {root / relative}"
|
|
506
|
+
) from error
|
|
507
|
+
finally:
|
|
508
|
+
for descriptor in reversed(descriptors):
|
|
509
|
+
os.close(descriptor)
|
|
510
|
+
try:
|
|
511
|
+
return data.decode("utf-8")
|
|
512
|
+
except UnicodeDecodeError as error:
|
|
513
|
+
raise DetectError(
|
|
514
|
+
f"cannot read {evidence_name} evidence: {root / relative}"
|
|
515
|
+
) from error
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def read_lstat_open_evidence(
|
|
519
|
+
relative: Path,
|
|
520
|
+
root: Path,
|
|
521
|
+
*,
|
|
522
|
+
missing_ok: bool,
|
|
523
|
+
evidence_name: str,
|
|
524
|
+
) -> Optional[str]:
|
|
525
|
+
current = root
|
|
526
|
+
expected = None
|
|
527
|
+
try:
|
|
528
|
+
for index, part in enumerate(relative.parts):
|
|
529
|
+
current = current / part
|
|
530
|
+
expected = os.stat(current, follow_symlinks=False)
|
|
531
|
+
if is_link_like(current, expected):
|
|
532
|
+
raise DetectError(
|
|
533
|
+
f"link-like {evidence_name} evidence is not allowed: {current}"
|
|
534
|
+
)
|
|
535
|
+
final = index == len(relative.parts) - 1
|
|
536
|
+
if not final and not stat.S_ISDIR(expected.st_mode):
|
|
537
|
+
raise DetectError(
|
|
538
|
+
f"{evidence_name} evidence parent is not a directory: {current}"
|
|
539
|
+
)
|
|
540
|
+
if final and not stat.S_ISREG(expected.st_mode):
|
|
541
|
+
raise DetectError(
|
|
542
|
+
f"{evidence_name} evidence is not a regular file: {current}"
|
|
543
|
+
)
|
|
544
|
+
descriptor = os.open(current, file_flags())
|
|
545
|
+
try:
|
|
546
|
+
actual = os.fstat(descriptor)
|
|
547
|
+
if not stat.S_ISREG(actual.st_mode) or not os.path.samestat(
|
|
548
|
+
expected,
|
|
549
|
+
actual,
|
|
550
|
+
):
|
|
551
|
+
raise DetectError(
|
|
552
|
+
f"{evidence_name} evidence changed while reading: {current}"
|
|
553
|
+
)
|
|
554
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
555
|
+
descriptor = None
|
|
556
|
+
data = stream.read()
|
|
557
|
+
finally:
|
|
558
|
+
if descriptor is not None:
|
|
559
|
+
os.close(descriptor)
|
|
560
|
+
except FileNotFoundError as error:
|
|
561
|
+
if missing_ok:
|
|
562
|
+
return None
|
|
563
|
+
raise DetectError(f"filesystem evidence disappeared: {current}") from error
|
|
564
|
+
except DetectError:
|
|
565
|
+
raise
|
|
566
|
+
except OSError as error:
|
|
567
|
+
raise DetectError(f"cannot read {evidence_name} evidence: {current}") from error
|
|
568
|
+
try:
|
|
569
|
+
return data.decode("utf-8")
|
|
570
|
+
except UnicodeDecodeError as error:
|
|
571
|
+
raise DetectError(f"cannot read {evidence_name} evidence: {current}") from error
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def is_thematic_break(line: str) -> bool:
|
|
575
|
+
leading = len(line) - len(line.lstrip(" "))
|
|
576
|
+
if leading > 3:
|
|
577
|
+
return False
|
|
578
|
+
compact = re.sub(r"[ \t]", "", line[leading:])
|
|
579
|
+
return (
|
|
580
|
+
len(compact) >= 3
|
|
581
|
+
and compact[0] in "*-_"
|
|
582
|
+
and all(character == compact[0] for character in compact)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def is_setext_title(line: str) -> bool:
|
|
587
|
+
indentation = line[: len(line) - len(line.lstrip(" \t"))]
|
|
588
|
+
return not (
|
|
589
|
+
len(indentation) > 3
|
|
590
|
+
or "\t" in indentation
|
|
591
|
+
or ATX_TITLE.fullmatch(line)
|
|
592
|
+
or SETEXT_UNDERLINE.fullmatch(line)
|
|
593
|
+
or SETEXT_BLOCK_START.match(line)
|
|
594
|
+
or HTML_BLOCK_START.match(line)
|
|
595
|
+
or is_thematic_break(line)
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def normalize_html_comments(text: str) -> tuple[str, bool]:
|
|
600
|
+
output = []
|
|
601
|
+
in_comment = False
|
|
602
|
+
shares_content_line = False
|
|
603
|
+
for line in text.splitlines(keepends=True):
|
|
604
|
+
content = line.rstrip("\r\n")
|
|
605
|
+
ending = line[len(content) :]
|
|
606
|
+
indentation = content[: len(content) - len(content.lstrip(" \t"))]
|
|
607
|
+
if not in_comment and (len(indentation) > 3 or "\t" in indentation):
|
|
608
|
+
output.append(line)
|
|
609
|
+
continue
|
|
610
|
+
visible = []
|
|
611
|
+
cursor = 0
|
|
612
|
+
line_has_comment = in_comment
|
|
613
|
+
while cursor < len(content):
|
|
614
|
+
if in_comment:
|
|
615
|
+
closing = content.find("-->", cursor)
|
|
616
|
+
if closing < 0:
|
|
617
|
+
cursor = len(content)
|
|
618
|
+
continue
|
|
619
|
+
cursor = closing + 3
|
|
620
|
+
in_comment = False
|
|
621
|
+
continue
|
|
622
|
+
opening = content.find("<!--", cursor)
|
|
623
|
+
if opening < 0:
|
|
624
|
+
visible.append(content[cursor:])
|
|
625
|
+
cursor = len(content)
|
|
626
|
+
continue
|
|
627
|
+
line_has_comment = True
|
|
628
|
+
visible.append(content[cursor:opening])
|
|
629
|
+
cursor = opening + 4
|
|
630
|
+
in_comment = True
|
|
631
|
+
visible_line = "".join(visible)
|
|
632
|
+
if line_has_comment and visible_line.strip():
|
|
633
|
+
shares_content_line = True
|
|
634
|
+
output.append(visible_line)
|
|
635
|
+
if ending:
|
|
636
|
+
output.append(ending)
|
|
637
|
+
elif line_has_comment and not visible_line:
|
|
638
|
+
output.append("\n")
|
|
639
|
+
return "".join(output), shares_content_line
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def markdown_has_body(text: str) -> bool:
|
|
643
|
+
normalized, comment_shares_content = normalize_html_comments(text)
|
|
644
|
+
if comment_shares_content:
|
|
645
|
+
return True
|
|
646
|
+
lines = [line.rstrip() for line in normalized.splitlines()]
|
|
647
|
+
while lines and not lines[0].strip():
|
|
648
|
+
lines.pop(0)
|
|
649
|
+
while lines and not lines[-1].strip():
|
|
650
|
+
lines.pop()
|
|
651
|
+
if not lines:
|
|
652
|
+
return False
|
|
653
|
+
if len(lines) == 1 and ATX_TITLE.fullmatch(lines[0]):
|
|
654
|
+
return False
|
|
655
|
+
if (
|
|
656
|
+
len(lines) >= 2
|
|
657
|
+
and all(line.strip() for line in lines)
|
|
658
|
+
and SETEXT_UNDERLINE.fullmatch(lines[-1])
|
|
659
|
+
and all(is_setext_title(line) for line in lines[:-1])
|
|
660
|
+
):
|
|
661
|
+
return False
|
|
662
|
+
return True
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def file_kind(
|
|
666
|
+
relative: str,
|
|
667
|
+
root: Path,
|
|
668
|
+
*,
|
|
669
|
+
tracked: bool,
|
|
670
|
+
index_oid: Optional[str] = None,
|
|
671
|
+
) -> Optional[str]:
|
|
672
|
+
if (
|
|
673
|
+
is_ignored(relative)
|
|
674
|
+
or relative == ".project"
|
|
675
|
+
or relative.startswith(".project/")
|
|
676
|
+
):
|
|
677
|
+
return None
|
|
678
|
+
relative_path = PurePosixPath(relative)
|
|
679
|
+
path = root.joinpath(*relative_path.parts)
|
|
680
|
+
name = relative_path.name
|
|
681
|
+
stem = PurePosixPath(name).stem.casefold()
|
|
682
|
+
suffix = PurePosixPath(name).suffix
|
|
683
|
+
if name in MANIFEST_NAMES or suffix in MANIFEST_SUFFIXES:
|
|
684
|
+
return "manifest"
|
|
685
|
+
if suffix.casefold() in SOURCE_SUFFIXES:
|
|
686
|
+
return "source"
|
|
687
|
+
if (
|
|
688
|
+
suffix.casefold() in MARKDOWN_SUFFIXES or name.casefold() == "readme"
|
|
689
|
+
) and stem not in GREENFIELD_DOC_STEMS:
|
|
690
|
+
text = read_regular_evidence(
|
|
691
|
+
path,
|
|
692
|
+
root,
|
|
693
|
+
missing_ok=tracked,
|
|
694
|
+
evidence_name="Markdown",
|
|
695
|
+
)
|
|
696
|
+
if text is None and tracked:
|
|
697
|
+
if index_oid is None:
|
|
698
|
+
raise DetectError(f"missing staged Markdown blob identity: {relative}")
|
|
699
|
+
text = read_git_index_blob(root, relative, index_oid)
|
|
700
|
+
if text is None:
|
|
701
|
+
return None
|
|
702
|
+
if not markdown_has_body(text):
|
|
703
|
+
return None
|
|
704
|
+
return "docs"
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def read_git_index_blob(root: Path, relative: str, oid: str) -> str:
|
|
709
|
+
try:
|
|
710
|
+
result = subprocess.run(
|
|
711
|
+
["git", "-C", str(root), "cat-file", "blob", oid],
|
|
712
|
+
capture_output=True,
|
|
713
|
+
check=False,
|
|
714
|
+
env=git_environment(),
|
|
715
|
+
)
|
|
716
|
+
except OSError as error:
|
|
717
|
+
raise DetectError(f"git cat-file failed: {error}") from error
|
|
718
|
+
if result.returncode != 0:
|
|
719
|
+
detail = result.stderr.decode("utf-8", "replace").strip()
|
|
720
|
+
suffix = f": {detail}" if detail else ""
|
|
721
|
+
raise DetectError(
|
|
722
|
+
f"cannot read staged Markdown evidence: {relative}{suffix}"
|
|
723
|
+
)
|
|
724
|
+
try:
|
|
725
|
+
return result.stdout.decode("utf-8")
|
|
726
|
+
except UnicodeDecodeError as error:
|
|
727
|
+
raise DetectError(f"cannot read Markdown evidence: {relative}") from error
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def parse_git_index_record(raw: bytes) -> GitIndexEntry:
|
|
731
|
+
try:
|
|
732
|
+
metadata, encoded_path = raw.split(b"\t", 1)
|
|
733
|
+
encoded_mode, encoded_oid, encoded_stage = metadata.split(b" ")
|
|
734
|
+
except ValueError as error:
|
|
735
|
+
raise DetectError("git ls-files returned malformed staged data") from error
|
|
736
|
+
if (
|
|
737
|
+
re.fullmatch(rb"[0-7]{6}", encoded_mode) is None
|
|
738
|
+
or re.fullmatch(rb"[0-9a-fA-F]+", encoded_oid) is None
|
|
739
|
+
or re.fullmatch(rb"[0-3]", encoded_stage) is None
|
|
740
|
+
):
|
|
741
|
+
raise DetectError("git ls-files returned malformed staged data")
|
|
742
|
+
try:
|
|
743
|
+
relative = encoded_path.decode("utf-8")
|
|
744
|
+
except UnicodeDecodeError as error:
|
|
745
|
+
raise DetectError("git ls-files returned non-UTF-8 paths") from error
|
|
746
|
+
return GitIndexEntry(
|
|
747
|
+
path=validated_git_path(relative),
|
|
748
|
+
mode=int(encoded_mode, 8),
|
|
749
|
+
oid=encoded_oid.decode("ascii").lower(),
|
|
750
|
+
stage=int(encoded_stage),
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def raise_walk_error(error: OSError) -> None:
|
|
755
|
+
raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def iter_worktree_files(root: Path) -> Iterable[str]:
|
|
759
|
+
if ANCHORED_EVIDENCE_SUPPORTED and LISTDIR_DIR_FD_SUPPORTED:
|
|
760
|
+
yield from iter_worktree_files_anchored(root)
|
|
761
|
+
return
|
|
762
|
+
for dirpath, dirnames, filenames in os.walk(
|
|
763
|
+
root,
|
|
764
|
+
followlinks=False,
|
|
765
|
+
onerror=raise_walk_error,
|
|
766
|
+
):
|
|
767
|
+
current = Path(dirpath)
|
|
768
|
+
relative_dir = "" if current == root else posix_relative(current, root)
|
|
769
|
+
kept_directories = []
|
|
770
|
+
for name in dirnames:
|
|
771
|
+
relative = f"{relative_dir}/{name}" if relative_dir else name
|
|
772
|
+
if (
|
|
773
|
+
is_ignored(relative)
|
|
774
|
+
or name == ".project"
|
|
775
|
+
or is_managed_pipeline_directory(relative, root)
|
|
776
|
+
):
|
|
777
|
+
continue
|
|
778
|
+
path = current / name
|
|
779
|
+
status = lstat_evidence(path, missing_ok=False)
|
|
780
|
+
if is_link_like(path, status):
|
|
781
|
+
continue
|
|
782
|
+
if not stat.S_ISDIR(status.st_mode):
|
|
783
|
+
raise DetectError(f"walk entry is not a directory: {path}")
|
|
784
|
+
kept_directories.append(name)
|
|
785
|
+
dirnames[:] = kept_directories
|
|
786
|
+
for name in filenames:
|
|
787
|
+
path = current / name
|
|
788
|
+
relative = f"{relative_dir}/{name}" if relative_dir else name
|
|
789
|
+
if is_ignored(relative) or is_managed_pipeline_artifact(relative, root):
|
|
790
|
+
continue
|
|
791
|
+
status = lstat_evidence(path, missing_ok=False)
|
|
792
|
+
if is_link_like(path, status) or not stat.S_ISREG(status.st_mode):
|
|
793
|
+
continue
|
|
794
|
+
yield relative
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def iter_worktree_files_anchored(root: Path) -> Iterable[str]:
|
|
798
|
+
flags = directory_flags()
|
|
799
|
+
try:
|
|
800
|
+
root_fd = os.open(root, flags)
|
|
801
|
+
except OSError as error:
|
|
802
|
+
raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
|
|
803
|
+
try:
|
|
804
|
+
try:
|
|
805
|
+
root_status = os.fstat(root_fd)
|
|
806
|
+
except OSError as error:
|
|
807
|
+
raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
|
|
808
|
+
if not stat.S_ISDIR(root_status.st_mode):
|
|
809
|
+
raise DetectError(f"repo evidence is not a directory: {root}")
|
|
810
|
+
yield from iter_from_dir_fd(root_fd, "", root)
|
|
811
|
+
finally:
|
|
812
|
+
os.close(root_fd)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def iter_from_dir_fd(
|
|
816
|
+
dir_fd: int, relative_dir: str, root: Path
|
|
817
|
+
) -> Iterable[str]:
|
|
818
|
+
try:
|
|
819
|
+
names = os.listdir(dir_fd)
|
|
820
|
+
except OSError as error:
|
|
821
|
+
raise DetectError(f"cannot traverse filesystem evidence: {error}") from error
|
|
822
|
+
for name in names:
|
|
823
|
+
relative = f"{relative_dir}/{name}" if relative_dir else name
|
|
824
|
+
if is_ignored(relative) or name == ".project":
|
|
825
|
+
continue
|
|
826
|
+
path = root.joinpath(*PurePosixPath(relative).parts)
|
|
827
|
+
try:
|
|
828
|
+
status = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
829
|
+
except OSError as error:
|
|
830
|
+
raise DetectError(
|
|
831
|
+
f"cannot inspect filesystem evidence: {relative}: {error}"
|
|
832
|
+
) from error
|
|
833
|
+
if is_link_like(path, status):
|
|
834
|
+
continue
|
|
835
|
+
if stat.S_ISDIR(status.st_mode):
|
|
836
|
+
try:
|
|
837
|
+
child = os.open(name, directory_flags(), dir_fd=dir_fd)
|
|
838
|
+
except OSError as error:
|
|
839
|
+
raise DetectError(
|
|
840
|
+
f"cannot traverse filesystem evidence: {relative}: {error}"
|
|
841
|
+
) from error
|
|
842
|
+
try:
|
|
843
|
+
try:
|
|
844
|
+
actual = os.fstat(child)
|
|
845
|
+
except OSError as error:
|
|
846
|
+
raise DetectError(
|
|
847
|
+
f"cannot traverse filesystem evidence: {relative}: {error}"
|
|
848
|
+
) from error
|
|
849
|
+
if not stat.S_ISDIR(actual.st_mode) or not os.path.samestat(
|
|
850
|
+
status, actual
|
|
851
|
+
):
|
|
852
|
+
raise DetectError(
|
|
853
|
+
f"filesystem evidence changed while traversing: {relative}"
|
|
854
|
+
)
|
|
855
|
+
if is_verified_skill_bundle_at(relative, child):
|
|
856
|
+
continue
|
|
857
|
+
yield from iter_from_dir_fd(child, relative, root)
|
|
858
|
+
finally:
|
|
859
|
+
os.close(child)
|
|
860
|
+
continue
|
|
861
|
+
if not stat.S_ISREG(status.st_mode):
|
|
862
|
+
continue
|
|
863
|
+
if is_fixed_managed_pipeline_artifact(relative):
|
|
864
|
+
continue
|
|
865
|
+
yield relative
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def validated_git_path(relative: str) -> str:
|
|
869
|
+
path = PurePosixPath(relative)
|
|
870
|
+
if (
|
|
871
|
+
relative == "."
|
|
872
|
+
or path.is_absolute()
|
|
873
|
+
or ".." in path.parts
|
|
874
|
+
or (
|
|
875
|
+
os.sep != "/"
|
|
876
|
+
and (os.sep in relative or PureWindowsPath(relative).drive)
|
|
877
|
+
)
|
|
878
|
+
):
|
|
879
|
+
raise DetectError(f"git ls-files returned unsafe path: {relative!r}")
|
|
880
|
+
return relative
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def git_tracked_files(root: Path) -> tuple[GitIndexEntry, ...]:
|
|
884
|
+
git_dir = root / ".git"
|
|
885
|
+
git_status = lstat_evidence(git_dir, missing_ok=True)
|
|
886
|
+
if git_status is None:
|
|
887
|
+
return ()
|
|
888
|
+
if is_link_like(git_dir, git_status):
|
|
889
|
+
raise DetectError(f".git must be a real directory or pointer file: {git_dir}")
|
|
890
|
+
if stat.S_ISREG(git_status.st_mode):
|
|
891
|
+
pointer = read_regular_evidence(
|
|
892
|
+
git_dir,
|
|
893
|
+
root,
|
|
894
|
+
missing_ok=False,
|
|
895
|
+
evidence_name=".git pointer",
|
|
896
|
+
)
|
|
897
|
+
if pointer is None or re.fullmatch(
|
|
898
|
+
r"gitdir: ([^\0\r\n]+)(?:\r?\n)?", pointer
|
|
899
|
+
) is None:
|
|
900
|
+
raise DetectError(f".git must be a valid gitdir pointer file: {git_dir}")
|
|
901
|
+
elif not stat.S_ISDIR(git_status.st_mode):
|
|
902
|
+
raise DetectError(f".git must be a real directory or pointer file: {git_dir}")
|
|
903
|
+
try:
|
|
904
|
+
result = subprocess.run(
|
|
905
|
+
["git", "-C", str(root), "ls-files", "--stage", "-z"],
|
|
906
|
+
capture_output=True,
|
|
907
|
+
check=False,
|
|
908
|
+
env=git_environment(),
|
|
909
|
+
)
|
|
910
|
+
except OSError as error:
|
|
911
|
+
raise DetectError(f"git ls-files failed: {error}") from error
|
|
912
|
+
if result.returncode != 0:
|
|
913
|
+
detail = result.stderr.decode("utf-8", "replace").strip()
|
|
914
|
+
suffix = f": {detail}" if detail else ""
|
|
915
|
+
raise DetectError(f"git ls-files failed{suffix}")
|
|
916
|
+
entries = []
|
|
917
|
+
for raw in result.stdout.split(b"\0"):
|
|
918
|
+
if not raw:
|
|
919
|
+
continue
|
|
920
|
+
entry = parse_git_index_record(raw)
|
|
921
|
+
if is_ignored(entry.path):
|
|
922
|
+
continue
|
|
923
|
+
if entry.stage != 0:
|
|
924
|
+
raise DetectError(f"git index contains unmerged entry: {entry.path}")
|
|
925
|
+
if not stat.S_ISREG(entry.mode):
|
|
926
|
+
continue
|
|
927
|
+
entries.append(entry)
|
|
928
|
+
return tuple(entries)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def staged_skill_bundle_prefixes(
|
|
932
|
+
entries: Sequence[GitIndexEntry],
|
|
933
|
+
) -> frozenset[str]:
|
|
934
|
+
prefixes = set()
|
|
935
|
+
for entry in entries:
|
|
936
|
+
parts = PurePosixPath(entry.path).parts
|
|
937
|
+
for end in range(1, len(parts)):
|
|
938
|
+
bundle_parts = parts[:end]
|
|
939
|
+
if is_skill_bundle_name(
|
|
940
|
+
bundle_parts[-1]
|
|
941
|
+
) and is_verified_installer_bundle(bundle_parts):
|
|
942
|
+
prefixes.add("/".join(bundle_parts))
|
|
943
|
+
if (
|
|
944
|
+
len(parts) >= 2
|
|
945
|
+
and parts[-1] == "SKILL.md"
|
|
946
|
+
and is_skill_bundle_name(parts[-2])
|
|
947
|
+
):
|
|
948
|
+
prefixes.add("/".join(parts[:-1]))
|
|
949
|
+
return frozenset(prefixes)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def is_staged_skill_bundle_artifact(
|
|
953
|
+
relative: str, prefixes: frozenset[str]
|
|
954
|
+
) -> bool:
|
|
955
|
+
parts = PurePosixPath(relative).parts
|
|
956
|
+
return any(
|
|
957
|
+
"/".join(parts[:end]) in prefixes for end in range(1, len(parts))
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def project_path_inventory(
|
|
962
|
+
project: Path, root: Path
|
|
963
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
964
|
+
project_status = lstat_evidence(project, missing_ok=True)
|
|
965
|
+
if project_status is None:
|
|
966
|
+
return (), ()
|
|
967
|
+
if is_link_like(project, project_status) or not stat.S_ISDIR(
|
|
968
|
+
project_status.st_mode
|
|
969
|
+
):
|
|
970
|
+
relative = posix_relative(project, root)
|
|
971
|
+
return (relative,), (relative,)
|
|
972
|
+
try:
|
|
973
|
+
next(project.iterdir())
|
|
974
|
+
except StopIteration:
|
|
975
|
+
return (), ()
|
|
976
|
+
except OSError:
|
|
977
|
+
relative = posix_relative(project, root)
|
|
978
|
+
return (relative,), (relative,)
|
|
979
|
+
paths = []
|
|
980
|
+
unsafe_paths = []
|
|
981
|
+
for dirpath, dirnames, filenames in os.walk(
|
|
982
|
+
project,
|
|
983
|
+
followlinks=False,
|
|
984
|
+
onerror=raise_walk_error,
|
|
985
|
+
):
|
|
986
|
+
current = Path(dirpath)
|
|
987
|
+
linked_directories = []
|
|
988
|
+
for name in dirnames:
|
|
989
|
+
path = current / name
|
|
990
|
+
status = lstat_evidence(path, missing_ok=False)
|
|
991
|
+
if is_link_like(path, status):
|
|
992
|
+
linked_directories.append(name)
|
|
993
|
+
unsafe_paths.append(posix_relative(path, root))
|
|
994
|
+
elif not stat.S_ISDIR(status.st_mode):
|
|
995
|
+
linked_directories.append(name)
|
|
996
|
+
unsafe_paths.append(posix_relative(path, root))
|
|
997
|
+
paths.extend(
|
|
998
|
+
posix_relative(current / name, root) for name in linked_directories
|
|
999
|
+
)
|
|
1000
|
+
dirnames[:] = [name for name in dirnames if name not in linked_directories]
|
|
1001
|
+
for name in filenames:
|
|
1002
|
+
path = current / name
|
|
1003
|
+
relative = posix_relative(path, root)
|
|
1004
|
+
status = lstat_evidence(path, missing_ok=False)
|
|
1005
|
+
paths.append(relative)
|
|
1006
|
+
if is_link_like(path, status) or not stat.S_ISREG(status.st_mode):
|
|
1007
|
+
unsafe_paths.append(relative)
|
|
1008
|
+
if not filenames and not dirnames and current != project:
|
|
1009
|
+
paths.append(posix_relative(current, root))
|
|
1010
|
+
if not paths:
|
|
1011
|
+
try:
|
|
1012
|
+
names = os.listdir(project)
|
|
1013
|
+
except OSError as error:
|
|
1014
|
+
raise DetectError(
|
|
1015
|
+
f"cannot list project evidence: {project}: {error}"
|
|
1016
|
+
) from error
|
|
1017
|
+
paths = [posix_relative(project / name, root) for name in names]
|
|
1018
|
+
return tuple(sorted(paths)), tuple(sorted(unsafe_paths))
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def frontmatter_scalar(raw: str) -> str:
|
|
1022
|
+
value = raw.strip()
|
|
1023
|
+
if value.startswith('"'):
|
|
1024
|
+
try:
|
|
1025
|
+
decoded = json.loads(value)
|
|
1026
|
+
except json.JSONDecodeError as error:
|
|
1027
|
+
raise DetectError("STATE.md has malformed frontmatter") from error
|
|
1028
|
+
if not isinstance(decoded, str):
|
|
1029
|
+
raise DetectError("STATE.md has malformed frontmatter")
|
|
1030
|
+
value = decoded
|
|
1031
|
+
elif value.startswith("'"):
|
|
1032
|
+
quoted = re.fullmatch(r"'((?:[^']|'')*)'", value)
|
|
1033
|
+
if quoted is None:
|
|
1034
|
+
raise DetectError("STATE.md has malformed frontmatter")
|
|
1035
|
+
value = quoted.group(1).replace("''", "'")
|
|
1036
|
+
else:
|
|
1037
|
+
value = re.sub(r"[ \t]+#.*$", "", value).rstrip()
|
|
1038
|
+
if not re.fullmatch(r"[^\s#\[\]{},]+", value):
|
|
1039
|
+
raise DetectError("STATE.md has malformed frontmatter")
|
|
1040
|
+
if not value:
|
|
1041
|
+
raise DetectError("STATE.md has malformed frontmatter")
|
|
1042
|
+
return value
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def state_frontmatter(text: str) -> Optional[dict[str, str]]:
|
|
1046
|
+
lines = text.splitlines()
|
|
1047
|
+
if not lines or lines[0] != "---":
|
|
1048
|
+
return None
|
|
1049
|
+
try:
|
|
1050
|
+
end = lines.index("---", 1)
|
|
1051
|
+
except ValueError as error:
|
|
1052
|
+
raise DetectError("STATE.md frontmatter is not closed") from error
|
|
1053
|
+
fields = {}
|
|
1054
|
+
for line in lines[1:end]:
|
|
1055
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
1056
|
+
continue
|
|
1057
|
+
match = FRONTMATTER_FIELD_RE.fullmatch(line)
|
|
1058
|
+
if match is None:
|
|
1059
|
+
raise DetectError("STATE.md has malformed frontmatter")
|
|
1060
|
+
key = match.group(1)
|
|
1061
|
+
if key in fields:
|
|
1062
|
+
raise DetectError(f"STATE.md has duplicate frontmatter field: {key}")
|
|
1063
|
+
fields[key] = frontmatter_scalar(match.group(2))
|
|
1064
|
+
return fields
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def pipeline_marker(state: Path, root: Path) -> Optional[str]:
|
|
1068
|
+
text = read_regular_evidence(
|
|
1069
|
+
state,
|
|
1070
|
+
root,
|
|
1071
|
+
missing_ok=False,
|
|
1072
|
+
evidence_name="state",
|
|
1073
|
+
)
|
|
1074
|
+
if text is None:
|
|
1075
|
+
raise DetectError(f"filesystem evidence disappeared: {state}")
|
|
1076
|
+
fields = state_frontmatter(text)
|
|
1077
|
+
return fields.get("pipeline") if fields is not None else None
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def classify(repo: Path) -> dict:
|
|
1081
|
+
root = repo.resolve()
|
|
1082
|
+
root_status = lstat_evidence(root, missing_ok=True)
|
|
1083
|
+
if root_status is None or not stat.S_ISDIR(root_status.st_mode):
|
|
1084
|
+
raise DetectError(f"repo is not a directory: {root}")
|
|
1085
|
+
project = root / ".project"
|
|
1086
|
+
state = project / "STATE.md"
|
|
1087
|
+
orphan_paths, unsafe_paths = project_path_inventory(project, root)
|
|
1088
|
+
temporary_state_status = lstat_evidence(
|
|
1089
|
+
project / STATE_TEMP_NAME,
|
|
1090
|
+
missing_ok=True,
|
|
1091
|
+
)
|
|
1092
|
+
if (
|
|
1093
|
+
ANCHORED_STATE_CREATE_SUPPORTED
|
|
1094
|
+
and orphan_paths == (f".project/{STATE_TEMP_NAME}",)
|
|
1095
|
+
and not unsafe_paths
|
|
1096
|
+
and temporary_state_status is not None
|
|
1097
|
+
and not is_link_like(project / STATE_TEMP_NAME, temporary_state_status)
|
|
1098
|
+
and stat.S_ISREG(temporary_state_status.st_mode)
|
|
1099
|
+
and temporary_state_status.st_nlink == 1
|
|
1100
|
+
):
|
|
1101
|
+
orphan_paths = ()
|
|
1102
|
+
if unsafe_paths:
|
|
1103
|
+
return {
|
|
1104
|
+
"verdict": "orphan",
|
|
1105
|
+
"pipeline": None,
|
|
1106
|
+
"signals": [],
|
|
1107
|
+
"orphan_paths": list(unsafe_paths),
|
|
1108
|
+
"route": "recover-orphan",
|
|
1109
|
+
}
|
|
1110
|
+
project_status = lstat_evidence(project, missing_ok=True)
|
|
1111
|
+
if (
|
|
1112
|
+
project_status is not None
|
|
1113
|
+
and not is_link_like(project, project_status)
|
|
1114
|
+
and stat.S_ISDIR(project_status.st_mode)
|
|
1115
|
+
):
|
|
1116
|
+
state_status = lstat_evidence(state, missing_ok=True)
|
|
1117
|
+
if (
|
|
1118
|
+
state_status is not None
|
|
1119
|
+
and not is_link_like(state, state_status)
|
|
1120
|
+
and stat.S_ISREG(state_status.st_mode)
|
|
1121
|
+
):
|
|
1122
|
+
return {
|
|
1123
|
+
"verdict": "owned",
|
|
1124
|
+
"pipeline": pipeline_marker(state, root),
|
|
1125
|
+
"signals": [],
|
|
1126
|
+
"orphan_paths": [],
|
|
1127
|
+
"route": "existing-state",
|
|
1128
|
+
}
|
|
1129
|
+
if orphan_paths:
|
|
1130
|
+
return {
|
|
1131
|
+
"verdict": "orphan",
|
|
1132
|
+
"pipeline": None,
|
|
1133
|
+
"signals": [],
|
|
1134
|
+
"orphan_paths": list(orphan_paths),
|
|
1135
|
+
"route": "recover-orphan",
|
|
1136
|
+
}
|
|
1137
|
+
signals = []
|
|
1138
|
+
seen = set()
|
|
1139
|
+
for relative in iter_worktree_files(root):
|
|
1140
|
+
kind = file_kind(relative, root, tracked=False)
|
|
1141
|
+
if kind is None:
|
|
1142
|
+
continue
|
|
1143
|
+
item = (kind, relative)
|
|
1144
|
+
if item in seen:
|
|
1145
|
+
continue
|
|
1146
|
+
seen.add(item)
|
|
1147
|
+
signals.append({"kind": kind, "path": relative})
|
|
1148
|
+
tracked_entries = git_tracked_files(root)
|
|
1149
|
+
staged_bundles = staged_skill_bundle_prefixes(tracked_entries)
|
|
1150
|
+
for entry in tracked_entries:
|
|
1151
|
+
relative = entry.path
|
|
1152
|
+
if is_managed_pipeline_artifact(
|
|
1153
|
+
relative, root
|
|
1154
|
+
) or is_staged_skill_bundle_artifact(relative, staged_bundles):
|
|
1155
|
+
continue
|
|
1156
|
+
kind = file_kind(
|
|
1157
|
+
relative,
|
|
1158
|
+
root,
|
|
1159
|
+
tracked=True,
|
|
1160
|
+
index_oid=entry.oid,
|
|
1161
|
+
)
|
|
1162
|
+
if kind is None:
|
|
1163
|
+
continue
|
|
1164
|
+
item = ("git", relative)
|
|
1165
|
+
if item in seen or (kind, relative) in seen:
|
|
1166
|
+
continue
|
|
1167
|
+
seen.add(item)
|
|
1168
|
+
signals.append({"kind": "git", "path": relative})
|
|
1169
|
+
signals.sort(key=lambda item: (item["kind"], item["path"]))
|
|
1170
|
+
if signals:
|
|
1171
|
+
return {
|
|
1172
|
+
"verdict": "brownfield",
|
|
1173
|
+
"pipeline": None,
|
|
1174
|
+
"signals": signals,
|
|
1175
|
+
"orphan_paths": [],
|
|
1176
|
+
"route": "inspect",
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
"verdict": "greenfield",
|
|
1180
|
+
"pipeline": None,
|
|
1181
|
+
"signals": [],
|
|
1182
|
+
"orphan_paths": [],
|
|
1183
|
+
"route": "define",
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def project_slug(root: Path) -> str:
|
|
1188
|
+
slug = re.sub(r"[^a-z0-9]+", "-", root.name.casefold()).strip("-")
|
|
1189
|
+
return slug or "project"
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def filled_state_template(template: str, slug: str, phase: str) -> str:
|
|
1193
|
+
if "<slug>" not in template:
|
|
1194
|
+
raise DetectError("state template missing slug placeholder")
|
|
1195
|
+
text = template.replace("<slug>", slug)
|
|
1196
|
+
text, count = re.subn(r"(?m)^phase: define\b", f"phase: {phase}", text, count=1)
|
|
1197
|
+
if count != 1:
|
|
1198
|
+
raise DetectError("state template missing phase: define line")
|
|
1199
|
+
stamp = f"{date.today().isoformat()} — {phase} — project initialized"
|
|
1200
|
+
text = text.replace("YYYY-MM-DD — <phase> — project initialized", stamp)
|
|
1201
|
+
if "<slug>" in text or "YYYY-MM-DD" in text:
|
|
1202
|
+
raise DetectError("state template placeholder remains")
|
|
1203
|
+
return text
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def write_all(descriptor: int, payload: bytes) -> None:
|
|
1207
|
+
remaining = memoryview(payload)
|
|
1208
|
+
while remaining:
|
|
1209
|
+
written = os.write(descriptor, remaining)
|
|
1210
|
+
if written <= 0:
|
|
1211
|
+
raise DetectError("cannot create STATE.md: write made no progress")
|
|
1212
|
+
remaining = remaining[written:]
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def rollback_created_state(
|
|
1216
|
+
project_fd: int,
|
|
1217
|
+
created_status: Optional[os.stat_result],
|
|
1218
|
+
name: str,
|
|
1219
|
+
) -> None:
|
|
1220
|
+
if created_status is None:
|
|
1221
|
+
try:
|
|
1222
|
+
os.unlink(name, dir_fd=project_fd)
|
|
1223
|
+
except FileNotFoundError:
|
|
1224
|
+
return
|
|
1225
|
+
except OSError as error:
|
|
1226
|
+
raise DetectError(f"cannot roll back {name}: {error}") from error
|
|
1227
|
+
return
|
|
1228
|
+
try:
|
|
1229
|
+
current = os.stat(name, dir_fd=project_fd, follow_symlinks=False)
|
|
1230
|
+
except FileNotFoundError:
|
|
1231
|
+
return
|
|
1232
|
+
except OSError as error:
|
|
1233
|
+
raise DetectError(f"cannot roll back {name}: {error}") from error
|
|
1234
|
+
if (
|
|
1235
|
+
is_link_like(Path(name), current)
|
|
1236
|
+
or not stat.S_ISREG(current.st_mode)
|
|
1237
|
+
or not os.path.samestat(created_status, current)
|
|
1238
|
+
):
|
|
1239
|
+
return
|
|
1240
|
+
try:
|
|
1241
|
+
os.unlink(name, dir_fd=project_fd)
|
|
1242
|
+
except OSError as error:
|
|
1243
|
+
raise DetectError(f"cannot roll back {name}: {error}") from error
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def close_file_descriptors(
|
|
1247
|
+
*descriptors: Optional[int],
|
|
1248
|
+
) -> None:
|
|
1249
|
+
for descriptor in descriptors:
|
|
1250
|
+
if descriptor is None:
|
|
1251
|
+
continue
|
|
1252
|
+
try:
|
|
1253
|
+
os.close(descriptor)
|
|
1254
|
+
except OSError:
|
|
1255
|
+
pass
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def write_state_anchored(
|
|
1259
|
+
root: Path,
|
|
1260
|
+
content: str,
|
|
1261
|
+
expected_root: os.stat_result,
|
|
1262
|
+
expected_project: Optional[os.stat_result],
|
|
1263
|
+
) -> None:
|
|
1264
|
+
if not ANCHORED_STATE_CREATE_SUPPORTED:
|
|
1265
|
+
raise DetectError("anchored no-follow STATE.md creation is unavailable")
|
|
1266
|
+
payload = content.encode("utf-8")
|
|
1267
|
+
flags = directory_flags()
|
|
1268
|
+
root_fd = None
|
|
1269
|
+
project_fd = None
|
|
1270
|
+
state_fd = None
|
|
1271
|
+
state_created = False
|
|
1272
|
+
created_status = None
|
|
1273
|
+
created_name = STATE_TEMP_NAME
|
|
1274
|
+
try:
|
|
1275
|
+
try:
|
|
1276
|
+
root_fd = os.open(root, flags)
|
|
1277
|
+
except OSError as error:
|
|
1278
|
+
raise DetectError(
|
|
1279
|
+
f"cannot open repo for STATE.md creation: {error}"
|
|
1280
|
+
) from error
|
|
1281
|
+
opened_root = os.fstat(root_fd)
|
|
1282
|
+
if (
|
|
1283
|
+
is_link_like(root, opened_root)
|
|
1284
|
+
or not stat.S_ISDIR(opened_root.st_mode)
|
|
1285
|
+
or not os.path.samestat(expected_root, opened_root)
|
|
1286
|
+
):
|
|
1287
|
+
raise DetectError("repo changed after classification")
|
|
1288
|
+
if expected_project is None:
|
|
1289
|
+
try:
|
|
1290
|
+
os.mkdir(".project", dir_fd=root_fd)
|
|
1291
|
+
except FileExistsError as error:
|
|
1292
|
+
raise DetectError(".project changed after classification") from error
|
|
1293
|
+
except OSError as error:
|
|
1294
|
+
raise DetectError(f"cannot create .project: {error}") from error
|
|
1295
|
+
try:
|
|
1296
|
+
named_project = os.stat(
|
|
1297
|
+
".project",
|
|
1298
|
+
dir_fd=root_fd,
|
|
1299
|
+
follow_symlinks=False,
|
|
1300
|
+
)
|
|
1301
|
+
except OSError as error:
|
|
1302
|
+
raise DetectError(
|
|
1303
|
+
f"cannot inspect .project for STATE.md creation: {error}"
|
|
1304
|
+
) from error
|
|
1305
|
+
if is_link_like(root / ".project", named_project) or not stat.S_ISDIR(
|
|
1306
|
+
named_project.st_mode
|
|
1307
|
+
):
|
|
1308
|
+
raise DetectError("cannot create state through a non-directory .project")
|
|
1309
|
+
try:
|
|
1310
|
+
project_fd = os.open(".project", flags, dir_fd=root_fd)
|
|
1311
|
+
except OSError as error:
|
|
1312
|
+
raise DetectError(
|
|
1313
|
+
f"cannot open .project for STATE.md creation: {error}"
|
|
1314
|
+
) from error
|
|
1315
|
+
opened_project = os.fstat(project_fd)
|
|
1316
|
+
if (
|
|
1317
|
+
not stat.S_ISDIR(opened_project.st_mode)
|
|
1318
|
+
or not os.path.samestat(named_project, opened_project)
|
|
1319
|
+
or (
|
|
1320
|
+
expected_project is not None
|
|
1321
|
+
and not os.path.samestat(expected_project, opened_project)
|
|
1322
|
+
)
|
|
1323
|
+
):
|
|
1324
|
+
raise DetectError(".project changed after classification")
|
|
1325
|
+
lock_exclusive(project_fd)
|
|
1326
|
+
entries = set(os.listdir(project_fd))
|
|
1327
|
+
if entries not in (set(), {STATE_TEMP_NAME}):
|
|
1328
|
+
raise DetectError(".project changed after classification")
|
|
1329
|
+
create = (
|
|
1330
|
+
os.O_CREAT
|
|
1331
|
+
| os.O_RDWR
|
|
1332
|
+
| os.O_NOFOLLOW
|
|
1333
|
+
| getattr(os, "O_BINARY", 0)
|
|
1334
|
+
)
|
|
1335
|
+
try:
|
|
1336
|
+
state_fd = os.open(STATE_TEMP_NAME, create, 0o644, dir_fd=project_fd)
|
|
1337
|
+
lock_exclusive(state_fd)
|
|
1338
|
+
except OSError as error:
|
|
1339
|
+
raise DetectError(f"cannot reserve temporary STATE.md: {error}") from error
|
|
1340
|
+
state_created = True
|
|
1341
|
+
created_status = os.fstat(state_fd)
|
|
1342
|
+
if is_link_like(
|
|
1343
|
+
root / ".project" / STATE_TEMP_NAME, created_status
|
|
1344
|
+
) or not stat.S_ISREG(created_status.st_mode):
|
|
1345
|
+
raise DetectError("temporary STATE.md is not a regular file")
|
|
1346
|
+
if created_status.st_nlink != 1:
|
|
1347
|
+
raise DetectError("temporary STATE.md has an unexpected link count")
|
|
1348
|
+
try:
|
|
1349
|
+
named_temporary = os.stat(
|
|
1350
|
+
STATE_TEMP_NAME,
|
|
1351
|
+
dir_fd=project_fd,
|
|
1352
|
+
follow_symlinks=False,
|
|
1353
|
+
)
|
|
1354
|
+
except FileNotFoundError as error:
|
|
1355
|
+
if "STATE.md" in os.listdir(project_fd):
|
|
1356
|
+
raise DetectError("STATE.md already exists") from error
|
|
1357
|
+
raise DetectError("temporary STATE.md disappeared") from error
|
|
1358
|
+
if not os.path.samestat(created_status, named_temporary):
|
|
1359
|
+
raise DetectError("temporary STATE.md changed before writing")
|
|
1360
|
+
if "STATE.md" in os.listdir(project_fd):
|
|
1361
|
+
raise DetectError("STATE.md already exists")
|
|
1362
|
+
os.ftruncate(state_fd, 0)
|
|
1363
|
+
write_all(state_fd, payload)
|
|
1364
|
+
os.fsync(state_fd)
|
|
1365
|
+
current_root = lstat_evidence(root, missing_ok=False)
|
|
1366
|
+
current_project = os.stat(
|
|
1367
|
+
".project",
|
|
1368
|
+
dir_fd=root_fd,
|
|
1369
|
+
follow_symlinks=False,
|
|
1370
|
+
)
|
|
1371
|
+
current_project_fd = os.fstat(project_fd)
|
|
1372
|
+
current_state = os.stat(
|
|
1373
|
+
STATE_TEMP_NAME,
|
|
1374
|
+
dir_fd=project_fd,
|
|
1375
|
+
follow_symlinks=False,
|
|
1376
|
+
)
|
|
1377
|
+
if (
|
|
1378
|
+
is_link_like(root, current_root)
|
|
1379
|
+
or not os.path.samestat(opened_root, current_root)
|
|
1380
|
+
or is_link_like(root / ".project", current_project)
|
|
1381
|
+
or not stat.S_ISDIR(current_project.st_mode)
|
|
1382
|
+
or not os.path.samestat(opened_project, current_project)
|
|
1383
|
+
or not os.path.samestat(opened_project, current_project_fd)
|
|
1384
|
+
):
|
|
1385
|
+
raise DetectError("project identity changed while creating STATE.md")
|
|
1386
|
+
if (
|
|
1387
|
+
is_link_like(root / ".project" / STATE_TEMP_NAME, current_state)
|
|
1388
|
+
or not stat.S_ISREG(current_state.st_mode)
|
|
1389
|
+
or current_state.st_nlink != 1
|
|
1390
|
+
or not os.path.samestat(created_status, current_state)
|
|
1391
|
+
):
|
|
1392
|
+
raise DetectError("temporary STATE.md changed while writing")
|
|
1393
|
+
if set(os.listdir(project_fd)) != {STATE_TEMP_NAME}:
|
|
1394
|
+
raise DetectError(".project contents changed while creating STATE.md")
|
|
1395
|
+
try:
|
|
1396
|
+
os.link(
|
|
1397
|
+
STATE_TEMP_NAME,
|
|
1398
|
+
"STATE.md",
|
|
1399
|
+
src_dir_fd=project_fd,
|
|
1400
|
+
dst_dir_fd=project_fd,
|
|
1401
|
+
follow_symlinks=False,
|
|
1402
|
+
)
|
|
1403
|
+
except FileExistsError as error:
|
|
1404
|
+
raise DetectError("STATE.md already exists") from error
|
|
1405
|
+
created_name = "STATE.md"
|
|
1406
|
+
os.unlink(
|
|
1407
|
+
STATE_TEMP_NAME,
|
|
1408
|
+
dir_fd=project_fd,
|
|
1409
|
+
)
|
|
1410
|
+
published = os.stat("STATE.md", dir_fd=project_fd, follow_symlinks=False)
|
|
1411
|
+
if published.st_nlink != 1 or not os.path.samestat(created_status, published):
|
|
1412
|
+
raise DetectError("STATE.md changed while publishing")
|
|
1413
|
+
if set(os.listdir(project_fd)) != {"STATE.md"}:
|
|
1414
|
+
raise DetectError(".project contents changed while publishing STATE.md")
|
|
1415
|
+
os.fsync(project_fd)
|
|
1416
|
+
closing_state_fd = state_fd
|
|
1417
|
+
state_fd = None
|
|
1418
|
+
try:
|
|
1419
|
+
os.close(closing_state_fd)
|
|
1420
|
+
except OSError as error:
|
|
1421
|
+
raise DetectError(f"cannot close STATE.md: {error}") from error
|
|
1422
|
+
except BaseException as error:
|
|
1423
|
+
if state_created and project_fd is not None:
|
|
1424
|
+
try:
|
|
1425
|
+
rollback_created_state(
|
|
1426
|
+
project_fd,
|
|
1427
|
+
created_status,
|
|
1428
|
+
created_name,
|
|
1429
|
+
)
|
|
1430
|
+
except DetectError as rollback_error:
|
|
1431
|
+
raise rollback_error from error
|
|
1432
|
+
if isinstance(error, DetectError):
|
|
1433
|
+
raise
|
|
1434
|
+
if isinstance(error, OSError):
|
|
1435
|
+
raise DetectError(f"cannot create STATE.md: {error}") from error
|
|
1436
|
+
raise
|
|
1437
|
+
finally:
|
|
1438
|
+
close_file_descriptors(state_fd, project_fd, root_fd)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def initialize(
|
|
1442
|
+
repo: Path, template: Path, phase: Optional[str] = None
|
|
1443
|
+
) -> dict:
|
|
1444
|
+
if phase is not None and phase not in {"inspect", "define"}:
|
|
1445
|
+
raise DetectError(f"initialize phase must be inspect or define: {phase}")
|
|
1446
|
+
try:
|
|
1447
|
+
raw = template.read_text(encoding="utf-8")
|
|
1448
|
+
except OSError as error:
|
|
1449
|
+
raise DetectError(f"cannot read state template: {error}") from error
|
|
1450
|
+
root = repo.resolve()
|
|
1451
|
+
root_status = lstat_evidence(root, missing_ok=True)
|
|
1452
|
+
if root_status is None or is_link_like(root, root_status) or not stat.S_ISDIR(
|
|
1453
|
+
root_status.st_mode
|
|
1454
|
+
):
|
|
1455
|
+
raise DetectError(f"repo is not a directory: {root}")
|
|
1456
|
+
project_status = lstat_evidence(root / ".project", missing_ok=True)
|
|
1457
|
+
payload = classify(root)
|
|
1458
|
+
if payload["verdict"] not in {"brownfield", "greenfield"}:
|
|
1459
|
+
payload["wrote_state"] = False
|
|
1460
|
+
return payload
|
|
1461
|
+
expected = "inspect" if payload["verdict"] == "brownfield" else "define"
|
|
1462
|
+
if phase is None:
|
|
1463
|
+
phase = expected
|
|
1464
|
+
elif phase != expected:
|
|
1465
|
+
raise DetectError(
|
|
1466
|
+
f"initialize phase {phase} does not match verdict {payload['verdict']}"
|
|
1467
|
+
)
|
|
1468
|
+
if not ANCHORED_STATE_CREATE_SUPPORTED:
|
|
1469
|
+
payload["wrote_state"] = False
|
|
1470
|
+
payload["error"] = "anchored no-follow STATE.md creation is unavailable"
|
|
1471
|
+
return payload
|
|
1472
|
+
write_state_anchored(
|
|
1473
|
+
root,
|
|
1474
|
+
filled_state_template(raw, project_slug(root), phase),
|
|
1475
|
+
root_status,
|
|
1476
|
+
project_status,
|
|
1477
|
+
)
|
|
1478
|
+
payload["wrote_state"] = True
|
|
1479
|
+
return payload
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def emit(payload: dict, exit_code: int = 0) -> int:
|
|
1483
|
+
json.dump(payload, sys.stdout, indent=2, sort_keys=True)
|
|
1484
|
+
sys.stdout.write("\n")
|
|
1485
|
+
return exit_code
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def parser() -> argparse.ArgumentParser:
|
|
1489
|
+
result = argparse.ArgumentParser(description=__doc__)
|
|
1490
|
+
result.add_argument("command", choices=("classify", "initialize"))
|
|
1491
|
+
result.add_argument("--repo", type=Path, required=True)
|
|
1492
|
+
result.add_argument("--template", type=Path)
|
|
1493
|
+
result.add_argument("--phase", choices=("inspect", "define"))
|
|
1494
|
+
return result
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
1498
|
+
arguments = parser().parse_args(argv)
|
|
1499
|
+
try:
|
|
1500
|
+
if arguments.command == "classify":
|
|
1501
|
+
return emit(classify(arguments.repo))
|
|
1502
|
+
if arguments.command == "initialize":
|
|
1503
|
+
if arguments.template is None:
|
|
1504
|
+
raise DetectError("initialize requires --template")
|
|
1505
|
+
payload = initialize(
|
|
1506
|
+
arguments.repo, arguments.template, arguments.phase
|
|
1507
|
+
)
|
|
1508
|
+
return emit(payload, 2 if payload.get("error") else 0)
|
|
1509
|
+
raise DetectError(f"unknown command: {arguments.command}")
|
|
1510
|
+
except DetectError as error:
|
|
1511
|
+
json.dump({"status": "error", "error": str(error)}, sys.stdout, indent=2)
|
|
1512
|
+
sys.stdout.write("\n")
|
|
1513
|
+
return 2
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
if __name__ == "__main__":
|
|
1517
|
+
raise SystemExit(main())
|