sarathi-sdlc 0.1.0__py3-none-any.whl
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.
- sarathi_sdlc/__init__.py +10 -0
- sarathi_sdlc/__main__.py +3 -0
- sarathi_sdlc/_bundle/CHANGELOG.md +134 -0
- sarathi_sdlc/_bundle/checkers/approvals.py +379 -0
- sarathi_sdlc/_bundle/checkers/check_code.py +256 -0
- sarathi_sdlc/_bundle/checkers/check_design.py +516 -0
- sarathi_sdlc/_bundle/checkers/check_plan.py +635 -0
- sarathi_sdlc/_bundle/checkers/check_spec.py +478 -0
- sarathi_sdlc/_bundle/checkers/complexity.py +78 -0
- sarathi_sdlc/_bundle/checkers/markdown_structure.py +35 -0
- sarathi_sdlc/_bundle/checkers/render_workflow_status.py +2397 -0
- sarathi_sdlc/_bundle/checkers/schemas.py +77 -0
- sarathi_sdlc/_bundle/checkers/waves.py +202 -0
- sarathi_sdlc/_bundle/docs/approval-gates.md +141 -0
- sarathi_sdlc/_bundle/docs/artifact-contracts.md +199 -0
- sarathi_sdlc/_bundle/docs/artifact-formatting.md +29 -0
- sarathi_sdlc/_bundle/docs/assurance-profiles.md +90 -0
- sarathi_sdlc/_bundle/docs/bootstrap-instructions.md +78 -0
- sarathi_sdlc/_bundle/docs/cleanup-pass.md +64 -0
- sarathi_sdlc/_bundle/docs/cross-cutting-concerns.md +20 -0
- sarathi_sdlc/_bundle/docs/feedback-and-learning.md +123 -0
- sarathi_sdlc/_bundle/docs/process-maintenance.md +91 -0
- sarathi_sdlc/_bundle/docs/progressive-disclosure.md +105 -0
- sarathi_sdlc/_bundle/docs/project-entry.md +156 -0
- sarathi_sdlc/_bundle/docs/release-process.md +119 -0
- sarathi_sdlc/_bundle/docs/review-verification-checklist.md +63 -0
- sarathi_sdlc/_bundle/docs/sarathi.html +987 -0
- sarathi_sdlc/_bundle/docs/simplicity-first.md +119 -0
- sarathi_sdlc/_bundle/docs/simplify-pass.md +68 -0
- sarathi_sdlc/_bundle/docs/slug-id-migration.md +89 -0
- sarathi_sdlc/_bundle/docs/srs-authoring.md +161 -0
- sarathi_sdlc/_bundle/docs/test-ownership.md +68 -0
- sarathi_sdlc/_bundle/docs/work-decomposition.md +104 -0
- sarathi_sdlc/_bundle/docs/work-in-progress.md +125 -0
- sarathi_sdlc/_bundle/docs/workflow-status.md +230 -0
- sarathi_sdlc/_bundle/prompts/code-assess.prompt.md +53 -0
- sarathi_sdlc/_bundle/prompts/code-create.prompt.md +85 -0
- sarathi_sdlc/_bundle/prompts/code-review.prompt.md +46 -0
- sarathi_sdlc/_bundle/prompts/code-verify.prompt.md +72 -0
- sarathi_sdlc/_bundle/prompts/design-assess.prompt.md +32 -0
- sarathi_sdlc/_bundle/prompts/design-create.prompt.md +90 -0
- sarathi_sdlc/_bundle/prompts/design-review.prompt.md +43 -0
- sarathi_sdlc/_bundle/prompts/design-verify.prompt.md +90 -0
- sarathi_sdlc/_bundle/prompts/plan-assess.prompt.md +40 -0
- sarathi_sdlc/_bundle/prompts/plan-create.prompt.md +105 -0
- sarathi_sdlc/_bundle/prompts/plan-review.prompt.md +42 -0
- sarathi_sdlc/_bundle/prompts/plan-verify.prompt.md +99 -0
- sarathi_sdlc/_bundle/prompts/spec-assess.prompt.md +32 -0
- sarathi_sdlc/_bundle/prompts/spec-create.prompt.md +96 -0
- sarathi_sdlc/_bundle/prompts/spec-review.prompt.md +39 -0
- sarathi_sdlc/_bundle/prompts/spec-verify.prompt.md +85 -0
- sarathi_sdlc/_bundle/prompts/workflow-status.prompt.md +41 -0
- sarathi_sdlc/_bundle/scripts/check_update.py +16 -0
- sarathi_sdlc/_bundle/scripts/install.ps1 +614 -0
- sarathi_sdlc/_bundle/scripts/install.sh +617 -0
- sarathi_sdlc/_bundle/scripts/pre-commit +22 -0
- sarathi_sdlc/_bundle/scripts/verify_release.py +36 -0
- sarathi_sdlc/_bundle/skills/sarathi/SKILL.md +154 -0
- sarathi_sdlc/_bundle/skills/sarathi/agents/openai.yaml +4 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/approvals.py +379 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/check_code.py +256 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/check_design.py +516 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/check_plan.py +635 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/check_spec.py +478 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/complexity.py +78 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/markdown_structure.py +35 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/render_workflow_status.py +2397 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/schemas.py +77 -0
- sarathi_sdlc/_bundle/skills/sarathi/checkers/waves.py +202 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/approval-gates.md +141 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/artifact-contracts.md +199 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/artifact-formatting.md +29 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/assurance-profiles.md +90 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/bootstrap-instructions.md +78 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/cleanup-pass.md +64 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/cross-cutting-concerns.md +20 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/feedback-and-learning.md +123 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/process-maintenance.md +91 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/progressive-disclosure.md +105 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/project-entry.md +156 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/release-process.md +119 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/review-verification-checklist.md +63 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/sarathi.html +987 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/simplicity-first.md +119 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/simplify-pass.md +68 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/slug-id-migration.md +89 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/srs-authoring.md +161 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/test-ownership.md +68 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/work-decomposition.md +104 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/work-in-progress.md +125 -0
- sarathi_sdlc/_bundle/skills/sarathi/docs/workflow-status.md +230 -0
- sarathi_sdlc/_bundle/skills/sarathi/manifest.json +6 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/code-assess.prompt.md +53 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/code-create.prompt.md +85 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/code-review.prompt.md +46 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/code-verify.prompt.md +72 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/design-assess.prompt.md +32 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/design-create.prompt.md +90 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/design-review.prompt.md +43 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/design-verify.prompt.md +90 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-assess.prompt.md +40 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-create.prompt.md +105 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-review.prompt.md +42 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/plan-verify.prompt.md +99 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-assess.prompt.md +32 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-create.prompt.md +96 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-review.prompt.md +39 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/spec-verify.prompt.md +85 -0
- sarathi_sdlc/_bundle/skills/sarathi/prompts/workflow-status.prompt.md +41 -0
- sarathi_sdlc/_bundle/skills/sarathi/scripts/check_update.py +138 -0
- sarathi_sdlc/_bundle/skills/srs-authoring/SKILL.md +45 -0
- sarathi_sdlc/_bundle/skills/srs-authoring/agents/openai.yaml +4 -0
- sarathi_sdlc/_bundle/skills/srs-authoring/references/srs-quality.md +160 -0
- sarathi_sdlc/cli.py +111 -0
- sarathi_sdlc-0.1.0.dist-info/METADATA +566 -0
- sarathi_sdlc-0.1.0.dist-info/RECORD +118 -0
- sarathi_sdlc-0.1.0.dist-info/WHEEL +4 -0
- sarathi_sdlc-0.1.0.dist-info/entry_points.txt +2 -0
sarathi_sdlc/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Sarathi SDLC distribution support."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("sarathi-sdlc")
|
|
7
|
+
except PackageNotFoundError: # Source checkout without an installed distribution.
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
sarathi_sdlc/__main__.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable Sarathi changes should be recorded here.
|
|
4
|
+
|
|
5
|
+
This project follows a Keep-a-Changelog style format with `Added`, `Changed`,
|
|
6
|
+
`Fixed`, `Deprecated`, `Removed`, `Security`, and `Docs` headings as needed.
|
|
7
|
+
Release tags use `vMAJOR.MINOR.PATCH` and should match `pyproject.toml`.
|
|
8
|
+
|
|
9
|
+
## Unreleased
|
|
10
|
+
|
|
11
|
+
## 0.1.0 - 2026-07-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Distribute Sarathi as the `sarathi-sdlc` Python package with a CLI that installs the
|
|
16
|
+
existing cross-platform skill, prompt, and checker targets from a wheel.
|
|
17
|
+
- Give installed skills explicit version metadata and a cached, fail-open PyPI update check
|
|
18
|
+
with an environment-variable opt-out.
|
|
19
|
+
- Publish tagged releases through a gated GitHub Actions Trusted Publisher workflow, then
|
|
20
|
+
create a GitHub Release with the verified wheel and source distribution attached.
|
|
21
|
+
- Show ordered `Wave N` labels beside scheduled child work in the workflow tree, show each
|
|
22
|
+
slice's PR state directly beneath its document chain, and let the compact parent-approval
|
|
23
|
+
status open a dialog with each approval record, stale hash prefixes, and the exact next
|
|
24
|
+
approval needed. Keep wave detail in the expanded tree row instead of a second page section.
|
|
25
|
+
- Add a delivery-progress status summary and feature, slice, wave, and text filters that
|
|
26
|
+
retain enough hierarchy to navigate from product progress to the detailed allocation tree.
|
|
27
|
+
|
|
28
|
+
- Add Lean, Standard, and High-assurance delivery profiles with context-triggered assurance
|
|
29
|
+
modules while preserving one feedback-driven lifecycle.
|
|
30
|
+
- Add a hard simplicity policy: process/product architecture separation, brownfield oracle
|
|
31
|
+
reuse, evidence-before-generalization, deletion-first review, conceptual complexity
|
|
32
|
+
budgets, and a three-PR default for bounded implementation slices.
|
|
33
|
+
- Add deterministic instruction budgets and a package-extraction regression example that
|
|
34
|
+
collapses speculative machinery into current contracts, consumer integration, and real
|
|
35
|
+
compatibility evidence.
|
|
36
|
+
- Add ordered wave declarations and a workflow-status projection: Breakdown plans assign
|
|
37
|
+
`WORK-*` members to `WAVE-*` sequences, while hash-current wave checkpoints preserve
|
|
38
|
+
completed feedback and parent-document impact evidence.
|
|
39
|
+
- Add an iterative feedback-and-learning policy: code-ready slices declare learning and
|
|
40
|
+
feedback targets, code assessment records honest feedback status and ancestor impact, and
|
|
41
|
+
the static process guide shows the inspect/adapt loop.
|
|
42
|
+
- Add bounded learning-wave guidance for agent parallelism, distinguishing preferred
|
|
43
|
+
intra-slice work, independent concurrent slices, and exceptional speculative downstream
|
|
44
|
+
work through execution, learning, and integration dependencies.
|
|
45
|
+
- Show the current learning target, feedback state, active wave and slices, WIP limit,
|
|
46
|
+
invalidation result, ancestor impact, and stop/replan triggers in workflow-status HTML;
|
|
47
|
+
preserve completed-slice learning evidence in hash-current code assessments.
|
|
48
|
+
- Add the top-level changelog and maintainer release/tagging process.
|
|
49
|
+
- Add a deterministic workflow-status HTML renderer and `/workflow-status` command that
|
|
50
|
+
visualize real artifact gates, known-unknown decomposition, PR slices, and mapped test
|
|
51
|
+
evidence as the same branching Spec/Design/Plan/Code tree used by the linked static process
|
|
52
|
+
guide.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Resolve transitive verify/review prompts from the sibling Sarathi bundle in direct stage
|
|
57
|
+
aliases, with project-install regression coverage.
|
|
58
|
+
- Install complete executable checker bundles, including shared parser/support modules.
|
|
59
|
+
- Require every delivery plan to declare complete ordered learning waves instead of
|
|
60
|
+
allowing an absent wave section to pass structurally.
|
|
61
|
+
- Separate four-plus-PR exception approval into `plan.complexity-approved`, allowing draft
|
|
62
|
+
verification before the targeted approval and reserving `plan.approved` for code entry.
|
|
63
|
+
- Match complexity approvals to Slice/change Plan artifacts, reject auto-approval, and let
|
|
64
|
+
later valid reapprovals supersede stale or ineligible earlier records.
|
|
65
|
+
- Validate an exact structured complexity budget and declared PR count instead of accepting
|
|
66
|
+
any non-empty one-line mention.
|
|
67
|
+
- Restore explicit TDD-exception categories, scope, replacement evidence, and qualitative
|
|
68
|
+
safety boundaries while replacing lexical Red/Green matching with labeled contracts.
|
|
69
|
+
- Ignore fenced Markdown examples when parsing complexity budgets, learning waves, and TDD
|
|
70
|
+
fields; require exact TDD-exception category values.
|
|
71
|
+
- Use one canonical `MILE|WORK|PR-AREA-NAME` grammar in plan verification and workflow
|
|
72
|
+
rendering; reject one-token and extra-token IDs, avoid valid-prefix matches, and display
|
|
73
|
+
malformed allocations as excluded repair warnings.
|
|
74
|
+
- Prevent status and process-guide nodes from collapsing below their content height in
|
|
75
|
+
mobile column layouts; add Chromium assertions for clipping, overlap, and horizontal
|
|
76
|
+
overflow at mobile and desktop viewports.
|
|
77
|
+
|
|
78
|
+
### Changed
|
|
79
|
+
|
|
80
|
+
- Move wave scheduling to Breakdown plans: they group `WORK-*` children, while an
|
|
81
|
+
Implementation plan contains the PRs for one child. Plan and status checks enforce that
|
|
82
|
+
separation.
|
|
83
|
+
- Show feature-owned and product-owned slices separately in workflow status, including
|
|
84
|
+
nested allocation paths and external artifact links where configured.
|
|
85
|
+
|
|
86
|
+
- Rewrite the routing skill, stage prompts, core process docs, static guide, and workflow
|
|
87
|
+
status copy in plain language while preserving checker fields, approval boundaries,
|
|
88
|
+
independent assessment, TDD, feedback, and safety rules.
|
|
89
|
+
- Consolidate shared policy into triggered references and reduce the canonical stage prompt
|
|
90
|
+
surface from roughly 5,000 lines to about 1,000 lines.
|
|
91
|
+
- Make simplify passes capable of requiring upstream spec/design/plan revision when an
|
|
92
|
+
accepted artifact is itself overbuilt.
|
|
93
|
+
- Distinguish mapped test evidence from assessed or completed code: hash-current passing
|
|
94
|
+
code-assessment entries display `Assessed`, while hash-current `code_slice.approved`
|
|
95
|
+
records display `Completed`.
|
|
96
|
+
- Publish installed skill manifests by atomic replacement so agents cannot observe a
|
|
97
|
+
truncated YAML frontmatter block while an installation is running.
|
|
98
|
+
- Avoid treating `.sdlc/test-traceability.yaml` path references as malformed
|
|
99
|
+
`TEST-*` IDs during plan/code structural checks.
|
|
100
|
+
- Define approval as permission for the next learning step rather than artifact freeze;
|
|
101
|
+
require post-slice revision decisions for affected specs, designs, plans, integration
|
|
102
|
+
work, and process tools before learning-dependent work continues.
|
|
103
|
+
- Rework live workflow status around a compact executive summary and progressively
|
|
104
|
+
disclosed allocation tree: the active branch opens by default, inactive branches remain
|
|
105
|
+
collapsed, and green/amber/gray icon states distinguish approval, work in progress, and
|
|
106
|
+
work not started without implying percentage complete.
|
|
107
|
+
- Clarify cross-scope test ownership: ancestor product/feature acceptance and integration
|
|
108
|
+
obligations must be allocated to code-ready descendant leaves instead of disappearing
|
|
109
|
+
during decomposition or becoming a final big-bang test phase.
|
|
110
|
+
- Define `WORK-*` as a parent Breakdown-plan allocation to an explicit child scope and
|
|
111
|
+
artifact chain; distinguish product-to-feature, feature-to-slice, and justified
|
|
112
|
+
product-to-integration-slice decomposition in prompts, docs, status views, and mechanical
|
|
113
|
+
plan checks.
|
|
114
|
+
- Replace the verbose static process guide with concise PR-sized and decomposable-product
|
|
115
|
+
trees that show feature, slice, and product-integration test leaves and their statuses;
|
|
116
|
+
encode artifact type by node background and work scope by explicit level tags, with
|
|
117
|
+
legend swatches that display the full node fill and accent edge.
|
|
118
|
+
|
|
119
|
+
### Removed
|
|
120
|
+
|
|
121
|
+
- Remove source-hash snapshot and provenance-table UI from workflow status; hashes remain
|
|
122
|
+
available to deterministic checks without becoming status-page content.
|
|
123
|
+
- Remove the workflow-and-learning diagnostics disclosure; delivery status is conveyed by the
|
|
124
|
+
progress summary, workflow tree, approval dialog, and only real plan-check warnings.
|
|
125
|
+
- Remove all PR, diff, module, and source-file line-count options, metrics, warnings,
|
|
126
|
+
reports, tests, and planning guidance. Reviewability now uses cohesion, conceptual
|
|
127
|
+
complexity, touch scope, evidence, rollback, and learning boundaries.
|
|
128
|
+
- Remove superseded private critical-review snapshots from active documentation.
|
|
129
|
+
|
|
130
|
+
## Pre-Changelog History
|
|
131
|
+
|
|
132
|
+
Sarathi existed before this changelog was introduced. Earlier history is
|
|
133
|
+
available in Git, including the prompt, skill, checker, installer, brownfield
|
|
134
|
+
adoption, SRS authoring, cleanup, simplify, and process-quality gate changes.
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Approval ledger helpers for deterministic SDLC gates.
|
|
2
|
+
|
|
3
|
+
The approval ledger is intentionally local and tool-agnostic. Projects may store
|
|
4
|
+
ticket or PR links as evidence, but the mechanical gate only trusts local YAML,
|
|
5
|
+
UTC timestamps, and artifact hashes. The ledger proves that a local attestation
|
|
6
|
+
record is well-formed and hash-current; it does not prove human intent or consent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
UTC_TS = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
|
18
|
+
APPROVED_STATUSES = {"approved", "auto-approved"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_comment(line: str) -> str:
|
|
22
|
+
in_quote: str | None = None
|
|
23
|
+
for i, ch in enumerate(line):
|
|
24
|
+
if ch in {"'", '"'} and (i == 0 or line[i - 1] != "\\"):
|
|
25
|
+
in_quote = None if in_quote == ch else ch
|
|
26
|
+
if ch == "#" and in_quote is None:
|
|
27
|
+
return line[:i]
|
|
28
|
+
return line
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _scalar(raw: str) -> Any:
|
|
32
|
+
value = raw.strip()
|
|
33
|
+
if not value:
|
|
34
|
+
return ""
|
|
35
|
+
if (value[0], value[-1]) in {('"', '"'), ("'", "'")}:
|
|
36
|
+
return value[1:-1]
|
|
37
|
+
if value in {"true", "True"}:
|
|
38
|
+
return True
|
|
39
|
+
if value in {"false", "False"}:
|
|
40
|
+
return False
|
|
41
|
+
if value in {"null", "Null", "None", "~"}:
|
|
42
|
+
return None
|
|
43
|
+
if value.startswith("[") and value.endswith("]"):
|
|
44
|
+
inner = value[1:-1].strip()
|
|
45
|
+
if not inner:
|
|
46
|
+
return []
|
|
47
|
+
return [_scalar(part.strip()) for part in inner.split(",")]
|
|
48
|
+
if re.fullmatch(r"-?\d+", value):
|
|
49
|
+
return int(value)
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _split_key_value(text: str) -> tuple[str, str]:
|
|
54
|
+
if ":" not in text:
|
|
55
|
+
raise ValueError(f"Expected key/value pair: {text!r}")
|
|
56
|
+
key, value = text.split(":", 1)
|
|
57
|
+
return key.strip(), value.strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _yaml_subset(text: str) -> Any:
|
|
61
|
+
"""Parse the small YAML subset used by approval ledgers.
|
|
62
|
+
|
|
63
|
+
Supports nested mappings, lists of mappings, scalars, quoted strings,
|
|
64
|
+
booleans, integers, nulls, and simple inline lists. It is not a general YAML
|
|
65
|
+
parser.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
lines: list[tuple[int, str]] = []
|
|
69
|
+
for raw in text.splitlines():
|
|
70
|
+
cleaned = _strip_comment(raw).rstrip()
|
|
71
|
+
if not cleaned.strip():
|
|
72
|
+
continue
|
|
73
|
+
indent = len(cleaned) - len(cleaned.lstrip(" "))
|
|
74
|
+
lines.append((indent, cleaned.strip()))
|
|
75
|
+
|
|
76
|
+
def parse_block(index: int, indent: int) -> tuple[Any, int]:
|
|
77
|
+
if index >= len(lines):
|
|
78
|
+
return {}, index
|
|
79
|
+
if lines[index][1].startswith("- "):
|
|
80
|
+
return parse_list(index, indent)
|
|
81
|
+
return parse_dict(index, indent)
|
|
82
|
+
|
|
83
|
+
def parse_dict(index: int, indent: int) -> tuple[dict[str, Any], int]:
|
|
84
|
+
out: dict[str, Any] = {}
|
|
85
|
+
while index < len(lines):
|
|
86
|
+
line_indent, content = lines[index]
|
|
87
|
+
if line_indent < indent or content.startswith("- "):
|
|
88
|
+
break
|
|
89
|
+
if line_indent > indent:
|
|
90
|
+
raise ValueError(f"Unexpected indentation near: {content!r}")
|
|
91
|
+
key, value = _split_key_value(content)
|
|
92
|
+
index += 1
|
|
93
|
+
if value:
|
|
94
|
+
out[key] = _scalar(value)
|
|
95
|
+
elif index < len(lines) and lines[index][0] > line_indent:
|
|
96
|
+
out[key], index = parse_block(index, lines[index][0])
|
|
97
|
+
else:
|
|
98
|
+
out[key] = None
|
|
99
|
+
return out, index
|
|
100
|
+
|
|
101
|
+
def parse_list(index: int, indent: int) -> tuple[list[Any], int]:
|
|
102
|
+
out: list[Any] = []
|
|
103
|
+
while index < len(lines):
|
|
104
|
+
line_indent, content = lines[index]
|
|
105
|
+
if line_indent < indent or not content.startswith("- "):
|
|
106
|
+
break
|
|
107
|
+
if line_indent > indent:
|
|
108
|
+
raise ValueError(f"Unexpected list indentation near: {content!r}")
|
|
109
|
+
item_text = content[2:].strip()
|
|
110
|
+
index += 1
|
|
111
|
+
if not item_text:
|
|
112
|
+
if index < len(lines) and lines[index][0] > line_indent:
|
|
113
|
+
item, index = parse_block(index, lines[index][0])
|
|
114
|
+
else:
|
|
115
|
+
item = None
|
|
116
|
+
elif ":" in item_text:
|
|
117
|
+
key, value = _split_key_value(item_text)
|
|
118
|
+
item = {key: _scalar(value)} if value else {key: None}
|
|
119
|
+
if not value and index < len(lines) and lines[index][0] > line_indent:
|
|
120
|
+
item[key], index = parse_block(index, lines[index][0])
|
|
121
|
+
if index < len(lines) and lines[index][0] > line_indent:
|
|
122
|
+
extra, index = parse_dict(index, lines[index][0])
|
|
123
|
+
item.update(extra)
|
|
124
|
+
else:
|
|
125
|
+
item = _scalar(item_text)
|
|
126
|
+
out.append(item)
|
|
127
|
+
return out, index
|
|
128
|
+
|
|
129
|
+
if not lines:
|
|
130
|
+
return {}
|
|
131
|
+
parsed, final = parse_block(0, lines[0][0])
|
|
132
|
+
if final != len(lines):
|
|
133
|
+
raise ValueError("Could not parse complete YAML subset")
|
|
134
|
+
return parsed
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_yaml_file(path: Path) -> Any:
|
|
138
|
+
text = path.read_text(encoding="utf-8")
|
|
139
|
+
return _yaml_subset(text)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def sha256_file(path: Path) -> str | None:
|
|
143
|
+
if not path.exists() or not path.is_file():
|
|
144
|
+
return None
|
|
145
|
+
digest = hashlib.sha256()
|
|
146
|
+
with path.open("rb") as handle:
|
|
147
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
148
|
+
digest.update(chunk)
|
|
149
|
+
return digest.hexdigest()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def valid_utc_timestamp(value: Any) -> bool:
|
|
153
|
+
if not isinstance(value, str) or not UTC_TS.fullmatch(value):
|
|
154
|
+
return False
|
|
155
|
+
try:
|
|
156
|
+
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
157
|
+
except ValueError:
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _as_list(value: Any) -> list[Any]:
|
|
163
|
+
if value is None:
|
|
164
|
+
return []
|
|
165
|
+
return value if isinstance(value, list) else [value]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _norm_rel(path: str | Path) -> str:
|
|
169
|
+
return Path(path).as_posix().lstrip("./")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _norm_project_path(project_root: Path, path: str | Path) -> str:
|
|
173
|
+
candidate = Path(path)
|
|
174
|
+
if candidate.is_absolute():
|
|
175
|
+
try:
|
|
176
|
+
return candidate.resolve().relative_to(project_root.resolve()).as_posix()
|
|
177
|
+
except ValueError:
|
|
178
|
+
return candidate.as_posix()
|
|
179
|
+
return _norm_rel(candidate)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _policy_allows(
|
|
183
|
+
record: dict[str, Any],
|
|
184
|
+
policy: dict[str, Any],
|
|
185
|
+
now: datetime | None = None,
|
|
186
|
+
) -> list[str]:
|
|
187
|
+
issues: list[str] = []
|
|
188
|
+
auto = policy.get("auto_approval") if isinstance(policy, dict) else None
|
|
189
|
+
if not isinstance(auto, dict) or auto.get("enabled") is not True:
|
|
190
|
+
return ["auto approval used but not enabled in gates policy"]
|
|
191
|
+
gate = record.get("gate")
|
|
192
|
+
scope = record.get("scope")
|
|
193
|
+
allowed_gates = {str(x) for x in _as_list(auto.get("allowed_gates"))}
|
|
194
|
+
allowed_scopes = {str(x) for x in _as_list(auto.get("allowed_scopes"))}
|
|
195
|
+
forbidden_gates = {str(x) for x in _as_list(auto.get("forbidden_gates"))}
|
|
196
|
+
if gate in forbidden_gates:
|
|
197
|
+
issues.append(f"gate {gate} is forbidden for auto approval")
|
|
198
|
+
if "*" not in allowed_gates and gate not in allowed_gates:
|
|
199
|
+
issues.append(f"gate {gate} is not allowed for auto approval")
|
|
200
|
+
if "*" not in allowed_scopes and scope not in allowed_scopes:
|
|
201
|
+
issues.append(f"scope {scope} is not allowed for auto approval")
|
|
202
|
+
expires_at = auto.get("expires_at")
|
|
203
|
+
if not valid_utc_timestamp(expires_at):
|
|
204
|
+
issues.append("auto approval policy expires_at must be UTC ISO-8601")
|
|
205
|
+
else:
|
|
206
|
+
expiry = datetime.strptime(str(expires_at), "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
207
|
+
tzinfo=UTC
|
|
208
|
+
)
|
|
209
|
+
if (now or datetime.now(UTC)) >= expiry:
|
|
210
|
+
issues.append("auto approval policy is expired")
|
|
211
|
+
return issues
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_approval_record(
|
|
215
|
+
record: Any,
|
|
216
|
+
project_root: Path,
|
|
217
|
+
gates_policy: dict[str, Any] | None = None,
|
|
218
|
+
) -> list[str]:
|
|
219
|
+
issues: list[str] = []
|
|
220
|
+
if not isinstance(record, dict):
|
|
221
|
+
return ["approval record must be a mapping"]
|
|
222
|
+
for key in (
|
|
223
|
+
"id",
|
|
224
|
+
"gate",
|
|
225
|
+
"scope",
|
|
226
|
+
"artifact",
|
|
227
|
+
"status",
|
|
228
|
+
"approved_by",
|
|
229
|
+
"approved_at",
|
|
230
|
+
):
|
|
231
|
+
if not record.get(key):
|
|
232
|
+
issues.append(f"missing {key}")
|
|
233
|
+
if record.get("status") not in APPROVED_STATUSES:
|
|
234
|
+
issues.append("status must be approved or auto-approved")
|
|
235
|
+
if not valid_utc_timestamp(record.get("approved_at")):
|
|
236
|
+
issues.append("approved_at must be UTC ISO-8601 like 2026-07-01T14:32:18Z")
|
|
237
|
+
artifact = record.get("artifact")
|
|
238
|
+
if not isinstance(artifact, dict):
|
|
239
|
+
issues.append("artifact must be a mapping")
|
|
240
|
+
return issues
|
|
241
|
+
for key in ("kind", "path", "sha256"):
|
|
242
|
+
if not artifact.get(key):
|
|
243
|
+
issues.append(f"artifact missing {key}")
|
|
244
|
+
if artifact.get("kind") == "marker-inventory":
|
|
245
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(artifact.get("sha256", ""))):
|
|
246
|
+
issues.append(
|
|
247
|
+
"marker inventory sha256 must be a lowercase SHA-256 hex digest"
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
artifact_path = project_root / str(artifact.get("path", ""))
|
|
251
|
+
actual_hash = sha256_file(artifact_path)
|
|
252
|
+
if actual_hash is None:
|
|
253
|
+
issues.append(f"artifact path does not exist: {artifact.get('path')}")
|
|
254
|
+
elif artifact.get("sha256") != actual_hash:
|
|
255
|
+
issues.append(f"artifact hash is stale: {artifact.get('path')}")
|
|
256
|
+
if record.get("status") == "auto-approved":
|
|
257
|
+
issues.extend(_policy_allows(record, gates_policy or {}))
|
|
258
|
+
return issues
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load_approval_context(
|
|
262
|
+
project_root: Path,
|
|
263
|
+
approvals_path: str = ".sdlc/approvals.yaml",
|
|
264
|
+
gates_path: str = ".sdlc/gates.yaml",
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
approvals_file = project_root / approvals_path
|
|
267
|
+
gates_file = project_root / gates_path
|
|
268
|
+
context: dict[str, Any] = {
|
|
269
|
+
"approvals_path": approvals_path,
|
|
270
|
+
"gates_path": gates_path,
|
|
271
|
+
"exists": approvals_file.exists(),
|
|
272
|
+
"records": [],
|
|
273
|
+
"invalid_records": [],
|
|
274
|
+
"load_error": None,
|
|
275
|
+
}
|
|
276
|
+
gates_policy: dict[str, Any] = {}
|
|
277
|
+
try:
|
|
278
|
+
if gates_file.exists():
|
|
279
|
+
loaded_policy = load_yaml_file(gates_file)
|
|
280
|
+
gates_policy = loaded_policy if isinstance(loaded_policy, dict) else {}
|
|
281
|
+
context["gates_policy_exists"] = gates_file.exists()
|
|
282
|
+
context["gates_policy"] = gates_policy
|
|
283
|
+
if not approvals_file.exists():
|
|
284
|
+
return context
|
|
285
|
+
data = load_yaml_file(approvals_file)
|
|
286
|
+
records = data.get("approvals") if isinstance(data, dict) else None
|
|
287
|
+
if not isinstance(records, list):
|
|
288
|
+
context["load_error"] = "approvals must be a list"
|
|
289
|
+
return context
|
|
290
|
+
context["records"] = records
|
|
291
|
+
invalid = []
|
|
292
|
+
for record in records:
|
|
293
|
+
issues = validate_approval_record(record, project_root, gates_policy)
|
|
294
|
+
if issues:
|
|
295
|
+
invalid.append(
|
|
296
|
+
{
|
|
297
|
+
"id": record.get("id") if isinstance(record, dict) else None,
|
|
298
|
+
"issues": issues,
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
context["invalid_records"] = invalid
|
|
302
|
+
except Exception as exc: # noqa: BLE001 - checker reports deterministic load errors.
|
|
303
|
+
context["load_error"] = str(exc)
|
|
304
|
+
return context
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def approval_requirement(
|
|
308
|
+
context: dict[str, Any],
|
|
309
|
+
project_root: Path,
|
|
310
|
+
gate: str,
|
|
311
|
+
artifact_path: str | Path,
|
|
312
|
+
scope: str | None = None,
|
|
313
|
+
artifact_kind: str | None = None,
|
|
314
|
+
expected_sha256: str | None = None,
|
|
315
|
+
allowed_statuses: set[str] | None = None,
|
|
316
|
+
) -> dict[str, Any]:
|
|
317
|
+
wanted = _norm_project_path(project_root, artifact_path)
|
|
318
|
+
result: dict[str, Any] = {
|
|
319
|
+
"gate": gate,
|
|
320
|
+
"artifact": wanted,
|
|
321
|
+
"scope": scope,
|
|
322
|
+
"approved": False,
|
|
323
|
+
"approval_id": None,
|
|
324
|
+
"status": None,
|
|
325
|
+
"evidence_semantics": (
|
|
326
|
+
"hash-current local attestation, not proof of human consent"
|
|
327
|
+
),
|
|
328
|
+
"issues": [],
|
|
329
|
+
}
|
|
330
|
+
if context.get("load_error"):
|
|
331
|
+
result["issues"].append(f"approval ledger load failed: {context['load_error']}")
|
|
332
|
+
return result
|
|
333
|
+
if not context.get("exists"):
|
|
334
|
+
result["issues"].append(f"approval ledger missing: {context['approvals_path']}")
|
|
335
|
+
return result
|
|
336
|
+
invalid_by_id = {
|
|
337
|
+
item["id"]: item["issues"] for item in context.get("invalid_records", [])
|
|
338
|
+
}
|
|
339
|
+
candidate_issues: list[str] = []
|
|
340
|
+
for record in context.get("records", []):
|
|
341
|
+
if not isinstance(record, dict):
|
|
342
|
+
continue
|
|
343
|
+
artifact = record.get("artifact")
|
|
344
|
+
if not isinstance(artifact, dict):
|
|
345
|
+
continue
|
|
346
|
+
if record.get("gate") != gate:
|
|
347
|
+
continue
|
|
348
|
+
if scope is not None and record.get("scope") != scope:
|
|
349
|
+
continue
|
|
350
|
+
if artifact_kind is not None and artifact.get("kind") != artifact_kind:
|
|
351
|
+
continue
|
|
352
|
+
if _norm_project_path(project_root, str(artifact.get("path", ""))) != wanted:
|
|
353
|
+
continue
|
|
354
|
+
if expected_sha256 is not None and artifact.get("sha256") != expected_sha256:
|
|
355
|
+
continue
|
|
356
|
+
result["approval_id"] = record.get("id")
|
|
357
|
+
result["status"] = record.get("status")
|
|
358
|
+
issues = list(invalid_by_id.get(record.get("id"), []))
|
|
359
|
+
if (
|
|
360
|
+
allowed_statuses is not None
|
|
361
|
+
and record.get("status") not in allowed_statuses
|
|
362
|
+
):
|
|
363
|
+
allowed = ", ".join(sorted(allowed_statuses))
|
|
364
|
+
issues.append(f"approval status must be one of: {allowed}")
|
|
365
|
+
if issues:
|
|
366
|
+
candidate_issues.extend(issues)
|
|
367
|
+
continue
|
|
368
|
+
result["approved"] = True
|
|
369
|
+
result["issues"] = []
|
|
370
|
+
return result
|
|
371
|
+
if candidate_issues:
|
|
372
|
+
result["issues"] = list(dict.fromkeys(candidate_issues))
|
|
373
|
+
else:
|
|
374
|
+
result["issues"].append("matching approval not found")
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def approval_gate_passed(requirements: list[dict[str, Any]]) -> bool:
|
|
379
|
+
return all(item.get("approved") for item in requirements)
|