@andresmassello/uscha 1.93.0 → 1.94.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/README.md +2 -2
- package/package.json +1 -1
- package/uscha-kit/.claude/skills/uscha-adr-refine/SKILL.md +30 -6
- package/uscha-kit/.claude/skills/uscha-devloop/qa_ledger.py +307 -5
- package/uscha-kit/.claude/skills/uscha-discovery/SKILL.md +31 -7
- package/uscha-kit/.claude-plugin/plugin.json +1 -1
- package/uscha-kit/.codex-plugin/plugin.json +1 -1
- package/uscha-kit/README.md +1 -1
- package/uscha-kit/VERSION +1 -1
- package/uscha-kit/skills/uscha-adr-refine/SKILL.md +30 -6
- package/uscha-kit/skills/uscha-devloop/qa_ledger.py +307 -5
- package/uscha-kit/skills/uscha-discovery/SKILL.md +31 -7
- package/uscha-kit/templates/CONSTITUTION.md +17 -0
- package/uscha-kit/templates/docs/adr/ADR-stack-template.md +73 -0
- package/uscha-kit/templates/docs/adr/README.md +5 -0
- package/uscha-kit/uscha.config.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Requires **Python 3.8+** on the machine (the engine is Python stdlib — no pip
|
|
|
40
40
|
runtime dependencies). The npm package is a thin router; the canonical installer is
|
|
41
41
|
`uscha-kit/install-uscha.py`.
|
|
42
42
|
|
|
43
|
-
**Kit v1.
|
|
43
|
+
**Kit v1.94.0** <!-- uscha:version --> · [uscha.dev](https://uscha.dev) ·
|
|
44
44
|
[changelog](https://github.com/andresmassello/uscha/blob/main/uscha-kit/CHANGELOG.md)
|
|
45
45
|
(the per-release changelogs live in the repo, not in the npm tarball)
|
|
46
46
|
|
|
@@ -85,7 +85,7 @@ automatic tool can perform: a human verdict.
|
|
|
85
85
|
from the compiled code: 0.828 measured (12 archetypes) — names AND behaviour
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
**What each arrow is, in the engine (kit 1.
|
|
88
|
+
**What each arrow is, in the engine (kit 1.94.0, 53 subcommands, all measured):**
|
|
89
89
|
|
|
90
90
|
| Leg | Subcommands | What it establishes |
|
|
91
91
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresmassello/uscha",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.94.0",
|
|
4
4
|
"description": "Spec-driven development for LLM coding agents: 9 skills + a stdlib evidence engine. Facts block, guesses advise; the human approves.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Andres Massello",
|
|
@@ -112,21 +112,45 @@ it's already fully answered. Keep a running list of OPEN GAPS and resolved decis
|
|
|
112
112
|
2. **Implicit decisions.** Surface the choices the request assumed: sync vs async,
|
|
113
113
|
storage, protocol, idempotency, transactional boundaries, who owns state. For each,
|
|
114
114
|
force an explicit decision and at least one considered alternative.
|
|
115
|
-
3. **
|
|
115
|
+
3. **Stack and lifecycle (MANDATORY — before any stack/architecture decision is
|
|
116
|
+
recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
|
|
117
|
+
DATE. A major version is a family; support is granted to a MINOR line, for a window,
|
|
118
|
+
by an upstream nobody in the room controls. Ask these one at a time, each with your
|
|
119
|
+
recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
|
|
120
|
+
answer from memory**; record the URL and the day you checked:
|
|
121
|
+
(a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
|
|
122
|
+
Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
|
|
123
|
+
(b) support window vs the declared go-live AND the expected operating life — does the
|
|
124
|
+
line stay patched for the whole operation? If not, move it up BEFORE building: a major
|
|
125
|
+
upgrade days before the milestone is the most expensive one there is;
|
|
126
|
+
(c) major dependencies and the upgrade policy — who approves one, and when it is
|
|
127
|
+
scheduled (aligned with the dev-loop's "zero new dependencies without approval");
|
|
128
|
+
(d) development and observability tools the operator wants from day one (consoles, APM,
|
|
129
|
+
admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
|
|
130
|
+
(e) compatibility with reused legacy modules — the minimum version they support.
|
|
131
|
+
The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
|
|
132
|
+
`lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
|
|
133
|
+
ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
|
|
134
|
+
`templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
|
|
135
|
+
compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
|
|
136
|
+
`qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
|
|
137
|
+
`no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
|
|
138
|
+
was CITED; it cannot see whether the citation is TRUE. That part is yours.
|
|
139
|
+
4. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
|
|
116
140
|
retries and backoff, 4xx vs 5xx, concurrency, partial/terminal states, what must NOT
|
|
117
141
|
happen. A feature without its failure behavior is half-specified.
|
|
118
|
-
|
|
142
|
+
5. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
|
|
119
143
|
rules that cannot be broken (money to the cent, numbering without gaps, never cross
|
|
120
144
|
environments/credentials, secrets never logged, auth/authz, data retention). Write/extend
|
|
121
145
|
`CONSTITUTION.md` (one invariant per line, CWE ref where it maps); these feed the
|
|
122
146
|
dev-loop severity gate and a breach is a BLOCKER. **An ADR may never contradict the
|
|
123
147
|
CONSTITUTION** — if a decision would, it's escalated, not recorded.
|
|
124
|
-
|
|
148
|
+
6. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
|
|
125
149
|
spec"). What you exclude is as important as what you include.
|
|
126
|
-
|
|
150
|
+
7. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
|
|
127
151
|
(tests green, documented, metrics published, runbook) AND success metrics (p95,
|
|
128
152
|
cost ceiling, zero orphaned records). Each item must be verifiable, not a feeling.
|
|
129
|
-
|
|
153
|
+
8. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
|
|
130
154
|
|
|
131
155
|
After each batch, reflect: "Decided: … / Still open: …". Move on only when the
|
|
132
156
|
current topic is closed.
|
|
@@ -146,7 +170,7 @@ have behavior, the scope has a boundary and the DoD is verifiable.") before Phas
|
|
|
146
170
|
## Phase B — Distill the artifacts
|
|
147
171
|
|
|
148
172
|
Only after convergence. Produce (and, if any new project-wide invariant surfaced in
|
|
149
|
-
step
|
|
173
|
+
step 5, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
|
|
150
174
|
ADR where it could later be "traded away"):
|
|
151
175
|
|
|
152
176
|
1. **One ADR per decision worth recording** at `docs/adr/ADR-NNN-<slug>.md`, format:
|
|
@@ -780,7 +780,9 @@ def _content_state(repo_path, repo_type, last_snapshot):
|
|
|
780
780
|
# verdict, or a report the record itself marked stale, is no anchor at all.
|
|
781
781
|
if ((snap.get("tests") or {}).get("freshness") or {}).get("status") == "stale":
|
|
782
782
|
return None
|
|
783
|
-
|
|
783
|
+
# the RECORD, not just the hash string: `_evidence_hash_matches` needs the `sha256_eol`
|
|
784
|
+
# marker beside it to know whether the compatibility comparison is still on offer
|
|
785
|
+
hashes = {r["path"]: r
|
|
784
786
|
for r in ((snap.get("tests") or {}).get("reports") or [])
|
|
785
787
|
if isinstance(r, dict) and r.get("path") and r.get("sha256")
|
|
786
788
|
and r.get("fresh_by") != "stale"}
|
|
@@ -814,7 +816,7 @@ def _report_fresh(repo_path, report_path, clock_fresh, content_state):
|
|
|
814
816
|
# cannot arise here, while normalizing one side would stop matching the recorded key.
|
|
815
817
|
rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
|
|
816
818
|
recorded = content_state["hashes"].get(rel)
|
|
817
|
-
if
|
|
819
|
+
if _evidence_hash_matches(report_path, recorded) is not True:
|
|
818
820
|
return None
|
|
819
821
|
return "content"
|
|
820
822
|
|
|
@@ -842,7 +844,10 @@ def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
|
|
|
842
844
|
"mtime_ns": mtime_ns,
|
|
843
845
|
"mtime": datetime.fromtimestamp(
|
|
844
846
|
mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
|
|
845
|
-
"sha256":
|
|
847
|
+
"sha256": _sha256_evidence(path),
|
|
848
|
+
# the marker says HOW the hash above was taken, so a reader never has to guess
|
|
849
|
+
# (and 1.93.0-and-older records, which have no marker, keep their compatibility)
|
|
850
|
+
"sha256_eol": "lf",
|
|
846
851
|
})
|
|
847
852
|
if not reports:
|
|
848
853
|
status = "not-applicable" if repo_type == "flutter" else "missing"
|
|
@@ -5304,6 +5309,59 @@ def _sha256_file(path):
|
|
|
5304
5309
|
return None
|
|
5305
5310
|
|
|
5306
5311
|
|
|
5312
|
+
# kit 1.93.1: the hash of a TEXT evidence file, EOL-NORMALIZED (CRLF -> LF before hashing).
|
|
5313
|
+
#
|
|
5314
|
+
# A JUnit report is text, and a version control system is allowed to rewrite its line endings
|
|
5315
|
+
# on checkout -- `* text=auto eol=lf` is the recommended `.gitattributes` and the kit's own.
|
|
5316
|
+
# The suite that produced the report on Windows wrote CRLF, the repository stores LF, and a
|
|
5317
|
+
# clean checkout therefore yields a file that is byte-different and semantically identical.
|
|
5318
|
+
# The exact-byte hash read that as `evidence altered after ingest`: 1.93.0 shipped with the
|
|
5319
|
+
# limit merely NAMED in SPEC 4, and the release machine's own board hit it the same day.
|
|
5320
|
+
# Normalizing the ONE difference git is allowed to introduce keeps the guarantee that matters
|
|
5321
|
+
# -- any other changed byte (a swapped log, an edited count, a different run) still fails.
|
|
5322
|
+
#
|
|
5323
|
+
# `_sha256_file` is deliberately left alone: compile-validate hashes MANIFEST UNITS, where the
|
|
5324
|
+
# exact bytes ARE the claim, and a manifest that tolerated a rewrite would be tolerating the
|
|
5325
|
+
# thing it exists to detect.
|
|
5326
|
+
def _sha256_evidence(path):
|
|
5327
|
+
try:
|
|
5328
|
+
with open(path, "rb") as fh:
|
|
5329
|
+
data = fh.read()
|
|
5330
|
+
except OSError:
|
|
5331
|
+
return None
|
|
5332
|
+
return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
|
|
5333
|
+
|
|
5334
|
+
|
|
5335
|
+
def _evidence_hash_matches(path, record):
|
|
5336
|
+
"""Does the file on disk still hash to what the RECORD carries? True / False, or None when
|
|
5337
|
+
the record carries no hash at all (the caller decides: UNMEASURED, never a pass).
|
|
5338
|
+
|
|
5339
|
+
Records written by 1.93.1 and later carry `sha256_eol: "lf"` and are compared NORMALIZED,
|
|
5340
|
+
full stop. A record written BEFORE that marker hashed the file's exact bytes, whatever
|
|
5341
|
+
line endings the machine that ran the suite happened to write -- and 1.93.0's own release
|
|
5342
|
+
record is the proof: the suite wrote CRLF on Windows, git stored LF, and the LF checkout
|
|
5343
|
+
matched neither the recorded hash nor its normalized form, because the recording was of the
|
|
5344
|
+
CRLF RENDERING of the same text. So a pre-marker record is compared against all three
|
|
5345
|
+
renderings of the bytes on disk: normalized, exact, and CRLF. The door is narrow on purpose
|
|
5346
|
+
-- it opens only for records that predate the marker, and it admits only the line-ending
|
|
5347
|
+
renderings of THIS file's text: a report whose content really changed matches none of them."""
|
|
5348
|
+
want = record.get("sha256") if isinstance(record, dict) else None
|
|
5349
|
+
if not want:
|
|
5350
|
+
return None
|
|
5351
|
+
if _sha256_evidence(path) == want:
|
|
5352
|
+
return True
|
|
5353
|
+
if record.get("sha256_eol") == "lf":
|
|
5354
|
+
return False
|
|
5355
|
+
try:
|
|
5356
|
+
with open(path, "rb") as fh:
|
|
5357
|
+
data = fh.read()
|
|
5358
|
+
except OSError:
|
|
5359
|
+
return False
|
|
5360
|
+
lf = data.replace(b"\r\n", b"\n")
|
|
5361
|
+
return want in (hashlib.sha256(data).hexdigest(),
|
|
5362
|
+
hashlib.sha256(lf.replace(b"\n", b"\r\n")).hexdigest())
|
|
5363
|
+
|
|
5364
|
+
|
|
5307
5365
|
def _contained_unit(base, unit):
|
|
5308
5366
|
"""A compilation's units must be RELATIVE paths CONTAINED within the compilation
|
|
5309
5367
|
directory: the manifest references what was compiled, and what was compiled lives with
|
|
@@ -8348,6 +8406,10 @@ def cmd_dashboard(args):
|
|
|
8348
8406
|
for r in {e.get("repo") for e in ledger["fast_path"]}}
|
|
8349
8407
|
if ledger.get("spec_drift"):
|
|
8350
8408
|
out["spec_drift"] = ledger["spec_drift"]
|
|
8409
|
+
# lifecycle (ADR-040): reused VERBATIM from the readiness payload above (one read, no
|
|
8410
|
+
# drift possible between the two surfaces), conditional for the same reason spec_drift is.
|
|
8411
|
+
if rd.get("lifecycle"):
|
|
8412
|
+
out["lifecycle"] = rd["lifecycle"]
|
|
8351
8413
|
# evidence_origin: the latest snapshot's origin per repo, and ONLY when one exists --
|
|
8352
8414
|
# a ledger predating ADR-007 keeps the exact prior schema (same conditional-key rule
|
|
8353
8415
|
# fast_path and spec_drift already follow).
|
|
@@ -8989,7 +9051,7 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8989
9051
|
elif not r.get("sha256"):
|
|
8990
9052
|
unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
|
|
8991
9053
|
"(older snapshot, or the file was unreadable)" % rel)
|
|
8992
|
-
elif
|
|
9054
|
+
elif _evidence_hash_matches(full, r) is not True:
|
|
8993
9055
|
failures.append("evidence altered after ingest: %s" % rel)
|
|
8994
9056
|
|
|
8995
9057
|
out["reasons"] = failures + unmeasured
|
|
@@ -9496,6 +9558,12 @@ def cmd_readiness(args):
|
|
|
9496
9558
|
"gates": _gate_rollup(ledger),
|
|
9497
9559
|
"by_repo": repos,
|
|
9498
9560
|
}
|
|
9561
|
+
# lifecycle (ADR-040): advisory, and CONDITIONAL like fast_path/spec_drift -- a project
|
|
9562
|
+
# that declares no lifecycle: block keeps the exact prior payload and the exact prior
|
|
9563
|
+
# text. Speaking only when it matters is the anti-ceremony rule applied to itself.
|
|
9564
|
+
_lc = _lifecycle_for(os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd())
|
|
9565
|
+
if _lc["declared"]:
|
|
9566
|
+
out["lifecycle"] = _lc
|
|
9499
9567
|
if args.json:
|
|
9500
9568
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
9501
9569
|
return
|
|
@@ -9576,6 +9644,8 @@ def cmd_readiness(args):
|
|
|
9576
9644
|
f"rising for {STALL_WINDOW} cycles: iterating more is not getting "
|
|
9577
9645
|
f"closer. Likely a design/SPEC problem — go back to the ADR / "
|
|
9578
9646
|
f"re-plan with the human (advisory)")
|
|
9647
|
+
if _lc["declared"]:
|
|
9648
|
+
print(" · " + _lifecycle_summary(_lc))
|
|
9579
9649
|
# rubrica (1.23.0): el ultimo grade por repo, siempre visible — guess
|
|
9580
9650
|
# estructurado que aconseja; si esta gateado ya bloqueo por el ledger
|
|
9581
9651
|
for rname, rnode in ledger["repos"].items():
|
|
@@ -10928,6 +10998,225 @@ _SC_SETEXT = re.compile(r"^(=+|-{2,})\s*$")
|
|
|
10928
10998
|
_SC_CHECKBOX = re.compile(r"^\[[ xX]\]\s*")
|
|
10929
10999
|
|
|
10930
11000
|
|
|
11001
|
+
# --------------------------------------------------------------------------- #
|
|
11002
|
+
# lifecycle (ADR-040: stack end-of-support vs go-live -- advisory, NEVER gates)
|
|
11003
|
+
# --------------------------------------------------------------------------- #
|
|
11004
|
+
# The method interrogates WHAT and WHY and has always treated the stack as a given
|
|
11005
|
+
# ("we use X"). A major line is not a decision: a minor line has an END-OF-SUPPORT
|
|
11006
|
+
# DATE, and a date that falls before the declared go-live is a forced upgrade nobody
|
|
11007
|
+
# planned. This dimension reads what the ADR CITES and compares it against the SPEC's
|
|
11008
|
+
# declared go-live. It cannot verify that the cited source tells the truth -- only that
|
|
11009
|
+
# a date and a source were cited at all. Advisory by construction, like spec-drift
|
|
11010
|
+
# (ADR-005): it never gates, never caps readiness, and never changes an exit code.
|
|
11011
|
+
_LC_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
11012
|
+
|
|
11013
|
+
|
|
11014
|
+
def _lc_valid_date(s):
|
|
11015
|
+
"""A cited date is real only if it is BOTH shaped YYYY-MM-DD and a calendar date:
|
|
11016
|
+
the format regex alone accepts 2025-13-40 (1.94.0 review), which then string-compares
|
|
11017
|
+
as if it were a real EOL. fromisoformat rejects the impossible month/day."""
|
|
11018
|
+
if not s or not _LC_DATE.match(s):
|
|
11019
|
+
return False
|
|
11020
|
+
try:
|
|
11021
|
+
datetime.strptime(s, "%Y-%m-%d")
|
|
11022
|
+
return True
|
|
11023
|
+
except ValueError:
|
|
11024
|
+
return False
|
|
11025
|
+
_LC_KEY = re.compile(r"^lifecycle\s*:\s*(.*)$")
|
|
11026
|
+
_LC_GOLIVE_FM = re.compile(r"^go[-_ ]?live\s*:\s*(.+?)\s*$", re.I)
|
|
11027
|
+
_LC_GOLIVE_LINE = re.compile(r"^\s*\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
|
|
11028
|
+
r"(\d{4}-\d{2}-\d{2})\b", re.I)
|
|
11029
|
+
_LC_FIELDS = ("component", "version", "eol", "source", "checked")
|
|
11030
|
+
|
|
11031
|
+
|
|
11032
|
+
def _lc_frontmatter(lines):
|
|
11033
|
+
"""The lines INSIDE a leading `---` frontmatter block, or None when there is none.
|
|
11034
|
+
An unterminated fence is not data: a half-written block reads as absent."""
|
|
11035
|
+
if not lines or lines[0].strip() != "---":
|
|
11036
|
+
return None
|
|
11037
|
+
body = []
|
|
11038
|
+
for ln in lines[1:]:
|
|
11039
|
+
if ln.strip() == "---":
|
|
11040
|
+
return body
|
|
11041
|
+
body.append(ln)
|
|
11042
|
+
return None
|
|
11043
|
+
|
|
11044
|
+
|
|
11045
|
+
def _lc_parse(lines):
|
|
11046
|
+
"""The `lifecycle:` list-of-dicts of a markdown file, parsed as a YAML SUBSET --
|
|
11047
|
+
stdlib only, the kit ships no PyYAML. Returns None when the key is absent (nothing to
|
|
11048
|
+
measure) and [] when the key is there but nothing under it parses (a NAMED absence:
|
|
11049
|
+
the caller reports UNMEASURED with a reason, never a silent pass)."""
|
|
11050
|
+
fm = _lc_frontmatter(lines)
|
|
11051
|
+
if fm is None:
|
|
11052
|
+
return None
|
|
11053
|
+
entries, in_lc, cur = None, False, None
|
|
11054
|
+
for ln in fm:
|
|
11055
|
+
s = ln.strip()
|
|
11056
|
+
if not in_lc:
|
|
11057
|
+
m = _LC_KEY.match(ln)
|
|
11058
|
+
if m:
|
|
11059
|
+
entries = []
|
|
11060
|
+
# only the block form is supported; an inline `lifecycle: [...]` parses to
|
|
11061
|
+
# nothing and reads as unparseable, never as "this ADR fixes no component"
|
|
11062
|
+
in_lc = not m.group(1).strip()
|
|
11063
|
+
continue
|
|
11064
|
+
if not s or s.startswith("#"):
|
|
11065
|
+
continue
|
|
11066
|
+
if not ln[:1].isspace(): # back to column 0 -> the key ended
|
|
11067
|
+
in_lc = False
|
|
11068
|
+
continue
|
|
11069
|
+
if s.startswith("- "):
|
|
11070
|
+
cur = {}
|
|
11071
|
+
entries.append(cur)
|
|
11072
|
+
s = s[2:].strip()
|
|
11073
|
+
if not s:
|
|
11074
|
+
continue
|
|
11075
|
+
if cur is None:
|
|
11076
|
+
continue
|
|
11077
|
+
k, sep, v = s.partition(":")
|
|
11078
|
+
if not sep:
|
|
11079
|
+
continue
|
|
11080
|
+
k = k.strip().lower()
|
|
11081
|
+
if k in _LC_FIELDS:
|
|
11082
|
+
cur[k] = v.strip().strip("\x27\x22")
|
|
11083
|
+
if entries is not None:
|
|
11084
|
+
entries = [e for e in entries if e]
|
|
11085
|
+
return entries
|
|
11086
|
+
|
|
11087
|
+
|
|
11088
|
+
def _lc_go_live(text):
|
|
11089
|
+
"""The declared go-live of a SPEC: frontmatter `go_live: YYYY-MM-DD`, or a
|
|
11090
|
+
`**Go-live:** YYYY-MM-DD` line anywhere in the body. None = not declared."""
|
|
11091
|
+
lines = [ln.rstrip("\r") for ln in (text or "").split("\n")]
|
|
11092
|
+
fm = _lc_frontmatter(lines)
|
|
11093
|
+
if fm:
|
|
11094
|
+
for ln in fm:
|
|
11095
|
+
if ln[:1].isspace():
|
|
11096
|
+
continue
|
|
11097
|
+
m = _LC_GOLIVE_FM.match(ln.strip())
|
|
11098
|
+
if m:
|
|
11099
|
+
v = m.group(1).strip().strip("\x27\x22")
|
|
11100
|
+
if _lc_valid_date(v):
|
|
11101
|
+
return v
|
|
11102
|
+
for ln in lines:
|
|
11103
|
+
m = _LC_GOLIVE_LINE.match(ln)
|
|
11104
|
+
if m and _lc_valid_date(m.group(1)):
|
|
11105
|
+
return m.group(1)
|
|
11106
|
+
return None
|
|
11107
|
+
|
|
11108
|
+
|
|
11109
|
+
def _lc_short(path):
|
|
11110
|
+
"""The ADR dir as the reader would type it: relative to the cwd when that is shorter,
|
|
11111
|
+
absolute otherwise (a different drive on Windows has no relative form)."""
|
|
11112
|
+
if not path:
|
|
11113
|
+
return "docs/adr"
|
|
11114
|
+
try:
|
|
11115
|
+
rel = os.path.relpath(path)
|
|
11116
|
+
except ValueError:
|
|
11117
|
+
return path
|
|
11118
|
+
return rel if len(rel) < len(path) else path
|
|
11119
|
+
|
|
11120
|
+
|
|
11121
|
+
def _lifecycle_report(adr_dir, spec_text):
|
|
11122
|
+
"""Per-component verdicts for every `lifecycle:` entry under `adr_dir`, compared
|
|
11123
|
+
against the go-live declared in `spec_text`. Whole-dimension UNMEASURED -- with the
|
|
11124
|
+
reason spelled out, never silence -- when no ADR carries the block, when the block is
|
|
11125
|
+
unparseable, or when no go-live is declared."""
|
|
11126
|
+
go_live = _lc_go_live(spec_text)
|
|
11127
|
+
comps, unparsed, names = [], [], []
|
|
11128
|
+
if adr_dir and os.path.isdir(adr_dir):
|
|
11129
|
+
names = sorted(f for f in os.listdir(adr_dir) if f.lower().endswith(".md"))
|
|
11130
|
+
for fn in names:
|
|
11131
|
+
try:
|
|
11132
|
+
with open(os.path.join(adr_dir, fn), "r", encoding="utf-8",
|
|
11133
|
+
errors="replace") as fh:
|
|
11134
|
+
lines = [ln.rstrip("\r") for ln in fh.read().split("\n")]
|
|
11135
|
+
except OSError:
|
|
11136
|
+
continue
|
|
11137
|
+
ent = _lc_parse(lines)
|
|
11138
|
+
if ent is None:
|
|
11139
|
+
continue
|
|
11140
|
+
if not ent:
|
|
11141
|
+
unparsed.append(fn)
|
|
11142
|
+
continue
|
|
11143
|
+
for e in ent:
|
|
11144
|
+
comps.append({"adr": fn, "component": e.get("component") or "(unnamed)",
|
|
11145
|
+
"version": e.get("version"), "eol": e.get("eol"),
|
|
11146
|
+
"source": e.get("source"), "checked": e.get("checked")})
|
|
11147
|
+
out = {"status": "UNMEASURED", "reason": None, "go_live": go_live,
|
|
11148
|
+
"adr_dir": adr_dir, "declared": bool(comps or unparsed),
|
|
11149
|
+
"components": comps, "n_components": len(comps), "n_expiring": 0,
|
|
11150
|
+
"unparsed_adrs": unparsed}
|
|
11151
|
+
if not comps:
|
|
11152
|
+
out["reason"] = ("lifecycle: block declared but unparseable in: "
|
|
11153
|
+
+ ", ".join(unparsed) if unparsed else
|
|
11154
|
+
"no ADR under %s carries a lifecycle: block"
|
|
11155
|
+
% _lc_short(adr_dir))
|
|
11156
|
+
return out
|
|
11157
|
+
if not go_live:
|
|
11158
|
+
for c in comps:
|
|
11159
|
+
c["status"], c["detail"] = "unmeasured", "no go-live declared"
|
|
11160
|
+
out["reason"] = ("no go-live declared in the SPEC (frontmatter 'go_live: "
|
|
11161
|
+
"YYYY-MM-DD' or a '**Go-live:** YYYY-MM-DD' line)")
|
|
11162
|
+
return out
|
|
11163
|
+
for c in comps:
|
|
11164
|
+
eol = (c.get("eol") or "").strip()
|
|
11165
|
+
cited = (c.get("source") or "").strip()
|
|
11166
|
+
if not eol or eol.lower() == "unknown" or not _lc_valid_date(eol):
|
|
11167
|
+
c["status"] = "no EOL cited"
|
|
11168
|
+
c["detail"] = ("declared unknown" if eol.lower() == "unknown"
|
|
11169
|
+
else ("not a YYYY-MM-DD date: %s" % eol if eol else "absent"))
|
|
11170
|
+
elif eol < go_live:
|
|
11171
|
+
# the sharper fact wins the label: a date that already expires is reported as
|
|
11172
|
+
# expiring even when uncited, and the missing source stays visible in the record
|
|
11173
|
+
c["status"] = "expires before go-live"
|
|
11174
|
+
c["detail"] = "%s < %s" % (eol, go_live)
|
|
11175
|
+
out["n_expiring"] += 1
|
|
11176
|
+
elif not cited:
|
|
11177
|
+
c["status"] = "no source cited"
|
|
11178
|
+
c["detail"] = "eol %s is asserted, not cited" % eol
|
|
11179
|
+
else:
|
|
11180
|
+
c["status"] = "ok"
|
|
11181
|
+
c["detail"] = "eol %s >= go-live %s" % (eol, go_live)
|
|
11182
|
+
out["status"] = "measured"
|
|
11183
|
+
return out
|
|
11184
|
+
|
|
11185
|
+
|
|
11186
|
+
def _lifecycle_summary(lc):
|
|
11187
|
+
"""The one advisory line, shared by spec-check and readiness so the two surfaces
|
|
11188
|
+
cannot drift apart."""
|
|
11189
|
+
if lc["status"] != "measured":
|
|
11190
|
+
return "lifecycle: UNMEASURED - %s" % lc["reason"]
|
|
11191
|
+
return ("lifecycle: %d component(s), %d expire before go-live %s (advisory)"
|
|
11192
|
+
% (lc["n_components"], lc["n_expiring"], lc["go_live"]))
|
|
11193
|
+
|
|
11194
|
+
|
|
11195
|
+
def _lifecycle_root(*paths):
|
|
11196
|
+
"""The project root a lifecycle read hangs off: the directory of the first path
|
|
11197
|
+
given, else the cwd."""
|
|
11198
|
+
for p in paths:
|
|
11199
|
+
if p:
|
|
11200
|
+
return os.path.dirname(os.path.abspath(p)) or os.getcwd()
|
|
11201
|
+
return os.getcwd()
|
|
11202
|
+
|
|
11203
|
+
|
|
11204
|
+
def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
|
|
11205
|
+
"""Read the dimension for a project root: `docs/adr` unless overridden, and the
|
|
11206
|
+
go-live from the text already in hand. `fallback` reads `<root>/SPEC.md` when no
|
|
11207
|
+
text was supplied -- it is OFF when the caller named the spec files itself, so an
|
|
11208
|
+
explicit `--spec` is never silently topped up from a file nobody asked for."""
|
|
11209
|
+
if fallback and not _lc_go_live(spec_text):
|
|
11210
|
+
sp = os.path.join(root, "SPEC.md")
|
|
11211
|
+
if os.path.isfile(sp):
|
|
11212
|
+
try:
|
|
11213
|
+
with open(sp, "r", encoding="utf-8", errors="replace") as fh:
|
|
11214
|
+
spec_text = (spec_text or "") + "\n" + fh.read()
|
|
11215
|
+
except OSError:
|
|
11216
|
+
pass
|
|
11217
|
+
return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
|
|
11218
|
+
|
|
11219
|
+
|
|
10931
11220
|
def _spec_check_text(text):
|
|
10932
11221
|
lines = text.split("\n")
|
|
10933
11222
|
n = len(lines)
|
|
@@ -11222,6 +11511,11 @@ def cmd_spec_check(args):
|
|
|
11222
11511
|
m = (_spec_check_text(text) if text.strip()
|
|
11223
11512
|
else {"blockers": [], "untestable": [], "stack_hits": [],
|
|
11224
11513
|
"non_ears": 0, "n_criteria": 0})
|
|
11514
|
+
# lifecycle (ADR-040): read-only and advisory -- it never touches `fail` below.
|
|
11515
|
+
lc = _lifecycle_for(_lifecycle_root(args.spec[0] if args.spec else None,
|
|
11516
|
+
args.acceptance),
|
|
11517
|
+
getattr(args, "adr_dir", None), text,
|
|
11518
|
+
fallback=not args.spec)
|
|
11225
11519
|
structural = len(m["blockers"]) + len(acc_block) # estructura = FACT -> bloquea
|
|
11226
11520
|
soft_find = len(m["untestable"]) + len(m["stack_hits"]) + len(acc_adv)
|
|
11227
11521
|
fail = structural > 0 or (args.strict and soft_find > 0)
|
|
@@ -11230,7 +11524,7 @@ def cmd_spec_check(args):
|
|
|
11230
11524
|
if args.json:
|
|
11231
11525
|
print(json.dumps({"verdict": verdict, "advisory": structural == 0,
|
|
11232
11526
|
"acceptance_blockers": acc_block,
|
|
11233
|
-
"acceptance_advisory": acc_adv, **m},
|
|
11527
|
+
"acceptance_advisory": acc_adv, "lifecycle": lc, **m},
|
|
11234
11528
|
indent=2, ensure_ascii=False))
|
|
11235
11529
|
sys.exit(1 if fail else 0)
|
|
11236
11530
|
|
|
@@ -11251,6 +11545,11 @@ def cmd_spec_check(args):
|
|
|
11251
11545
|
print(f" ~ stack named in criteria ({len(m['stack_hits'])}): {', '.join(terms[:6])} — the 'how' belongs in the ADR, not the SPEC")
|
|
11252
11546
|
if m["non_ears"] and m["n_criteria"]:
|
|
11253
11547
|
print(f" · advisory: {m['non_ears']}/{m['n_criteria']} criteria do not follow the EARS pattern (When/If/While … shall)")
|
|
11548
|
+
print(" · " + _lifecycle_summary(lc))
|
|
11549
|
+
for c in [x for x in lc["components"] if x.get("status") not in ("ok", None)][:5]:
|
|
11550
|
+
print(" %s %s %s - %s (%s)"
|
|
11551
|
+
% ("!" if c["status"] == "expires before go-live" else "~",
|
|
11552
|
+
c["component"], c.get("version") or "?", c["status"], c["detail"]))
|
|
11254
11553
|
print(" i consistency: INFERENTIAL (an uncorrelated checker), not this lint · "
|
|
11255
11554
|
"structure = FACT (blocks) · prose = advisory (--strict to gate)")
|
|
11256
11555
|
if verdict == "OK":
|
|
@@ -12471,6 +12770,9 @@ def build_parser():
|
|
|
12471
12770
|
help="validate ACCEPTANCE traceability (AC-n IDs): missing "
|
|
12472
12771
|
"file / zero criteria / zero traceable / duplicate "
|
|
12473
12772
|
"IDs block as structural FACTS")
|
|
12773
|
+
psc.add_argument("--adr-dir", default=None,
|
|
12774
|
+
help="ADRs whose lifecycle: blocks are read (ADR-040); default "
|
|
12775
|
+
"docs/adr beside the SPEC")
|
|
12474
12776
|
psc.add_argument("--strict", action="store_true",
|
|
12475
12777
|
help="also fail (exit 1) on soft findings: untestable criteria / stack named")
|
|
12476
12778
|
psc.add_argument("--json", action="store_true")
|
|
@@ -137,25 +137,49 @@ answer.
|
|
|
137
137
|
I deduce these core entities: … do they work for you, or is one missing?")
|
|
138
138
|
3. **Operation / API surface.** Propose the endpoints/operations and their
|
|
139
139
|
contracts (idempotency, status codes).
|
|
140
|
-
4. **
|
|
140
|
+
4. **Stack and lifecycle (MANDATORY — before any stack/architecture decision is
|
|
141
|
+
recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
|
|
142
|
+
DATE. A major version is a family; support is granted to a MINOR line, for a window,
|
|
143
|
+
by an upstream nobody in the room controls. Ask these one at a time, each with your
|
|
144
|
+
recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
|
|
145
|
+
answer from memory**; record the URL and the day you checked:
|
|
146
|
+
(a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
|
|
147
|
+
Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
|
|
148
|
+
(b) support window vs the declared go-live AND the expected operating life — does the
|
|
149
|
+
line stay patched for the whole operation? If not, move it up BEFORE building: a major
|
|
150
|
+
upgrade days before the milestone is the most expensive one there is;
|
|
151
|
+
(c) major dependencies and the upgrade policy — who approves one, and when it is
|
|
152
|
+
scheduled (aligned with the dev-loop's "zero new dependencies without approval");
|
|
153
|
+
(d) development and observability tools the operator wants from day one (consoles, APM,
|
|
154
|
+
admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
|
|
155
|
+
(e) compatibility with reused legacy modules — the minimum version they support.
|
|
156
|
+
The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
|
|
157
|
+
`lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
|
|
158
|
+
ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
|
|
159
|
+
`templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
|
|
160
|
+
compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
|
|
161
|
+
`qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
|
|
162
|
+
`no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
|
|
163
|
+
was CITED; it cannot see whether the citation is TRUE. That part is yours.
|
|
164
|
+
5. **Big decisions (→ ADR).** Propose 2–3 architecture options with trade-offs and
|
|
141
165
|
a recommended default: persistence, protocol, idempotency, sync/async, multi-tenancy.
|
|
142
|
-
|
|
166
|
+
6. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
|
|
143
167
|
concurrency, what must NOT happen.
|
|
144
|
-
|
|
168
|
+
7. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
|
|
145
169
|
rules that can't be broken (money to the cent, no numbering gaps, never cross
|
|
146
170
|
environments, secrets never logged, auth). Write/extend `CONSTITUTION.md` with these —
|
|
147
171
|
one invariant per line, with a CWE reference where it maps. They feed the severity gate
|
|
148
172
|
downstream, and a breach is a BLOCKER, never a trade-off.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
8. **Out of scope.** Explicit boundaries with forward references.
|
|
174
|
+
9. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
|
|
175
|
+
10. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
|
|
152
176
|
dimensions are negotiable (coverage, perf, security)?" Propose thresholds fit for
|
|
153
177
|
the risk profile (a payments core is not an internal dashboard). What the human
|
|
154
178
|
declares goes into `uscha.config.json` (`defaults.coverage_threshold`,
|
|
155
179
|
`defaults.readiness_caps`, `defaults.simplicity`) — a declared threshold reads as
|
|
156
180
|
**requerimiento (config)** in the engine's output; an undeclared one stays a kit
|
|
157
181
|
default (opinion) and is labeled as such. Declaring is committing the config.
|
|
158
|
-
|
|
182
|
+
11. **Residual risks and dependencies.** What's uncertain, what must exist first.
|
|
159
183
|
For each HIGH-uncertainty risk, ask (kit 1.19.0, Tip 21 'Prototype to Learn'):
|
|
160
184
|
"Does it warrant a time-boxed spike before freezing the SPEC?" A spike runs on a
|
|
161
185
|
`spike/*` branch and its ONLY legitimate output is an **ADR with lessons**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "uscha",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.94.0",
|
|
5
5
|
"displayName": "Uscha",
|
|
6
6
|
"description": "Spec-driven development for LLM coding agents: 9 skills (discovery, adr-refine, reverse-discovery, characterize, devloop, sysdoc, rubric, mirador, status) + a stdlib measurement engine (qa_ledger.py, 53 subcommands + universal installer + npm/npx router). Facts block, guesses advise; the human approves.",
|
|
7
7
|
"author": {
|
package/uscha-kit/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# uscha-kit
|
|
2
2
|
|
|
3
|
-
**Kit version:** v1.
|
|
3
|
+
**Kit version:** v1.94.0 <!-- uscha:version --> · **[uscha.dev](https://uscha.dev)**
|
|
4
4
|
|
|
5
5
|
Spec-driven orchestrator + multi-repo QA for Claude Code, with a deterministic ledger.
|
|
6
6
|
**Nine skills** (`uscha-discovery`, `uscha-adr-refine`, `uscha-devloop`, `uscha-sysdoc`, `uscha-reverse-discovery`,
|
package/uscha-kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
uscha-kit 1.
|
|
1
|
+
uscha-kit 1.94.0
|
|
@@ -112,21 +112,45 @@ it's already fully answered. Keep a running list of OPEN GAPS and resolved decis
|
|
|
112
112
|
2. **Implicit decisions.** Surface the choices the request assumed: sync vs async,
|
|
113
113
|
storage, protocol, idempotency, transactional boundaries, who owns state. For each,
|
|
114
114
|
force an explicit decision and at least one considered alternative.
|
|
115
|
-
3. **
|
|
115
|
+
3. **Stack and lifecycle (MANDATORY — before any stack/architecture decision is
|
|
116
|
+
recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
|
|
117
|
+
DATE. A major version is a family; support is granted to a MINOR line, for a window,
|
|
118
|
+
by an upstream nobody in the room controls. Ask these one at a time, each with your
|
|
119
|
+
recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
|
|
120
|
+
answer from memory**; record the URL and the day you checked:
|
|
121
|
+
(a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
|
|
122
|
+
Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
|
|
123
|
+
(b) support window vs the declared go-live AND the expected operating life — does the
|
|
124
|
+
line stay patched for the whole operation? If not, move it up BEFORE building: a major
|
|
125
|
+
upgrade days before the milestone is the most expensive one there is;
|
|
126
|
+
(c) major dependencies and the upgrade policy — who approves one, and when it is
|
|
127
|
+
scheduled (aligned with the dev-loop's "zero new dependencies without approval");
|
|
128
|
+
(d) development and observability tools the operator wants from day one (consoles, APM,
|
|
129
|
+
admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
|
|
130
|
+
(e) compatibility with reused legacy modules — the minimum version they support.
|
|
131
|
+
The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
|
|
132
|
+
`lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
|
|
133
|
+
ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
|
|
134
|
+
`templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
|
|
135
|
+
compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
|
|
136
|
+
`qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
|
|
137
|
+
`no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
|
|
138
|
+
was CITED; it cannot see whether the citation is TRUE. That part is yours.
|
|
139
|
+
4. **Behavior.** Happy path first, then the DIRTY cases: provider/timeout failures,
|
|
116
140
|
retries and backoff, 4xx vs 5xx, concurrency, partial/terminal states, what must NOT
|
|
117
141
|
happen. A feature without its failure behavior is half-specified.
|
|
118
|
-
|
|
142
|
+
5. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
|
|
119
143
|
rules that cannot be broken (money to the cent, numbering without gaps, never cross
|
|
120
144
|
environments/credentials, secrets never logged, auth/authz, data retention). Write/extend
|
|
121
145
|
`CONSTITUTION.md` (one invariant per line, CWE ref where it maps); these feed the
|
|
122
146
|
dev-loop severity gate and a breach is a BLOCKER. **An ADR may never contradict the
|
|
123
147
|
CONSTITUTION** — if a decision would, it's escalated, not recorded.
|
|
124
|
-
|
|
148
|
+
6. **Out of scope.** Explicit boundaries, with forward references ("X goes to a later
|
|
125
149
|
spec"). What you exclude is as important as what you include.
|
|
126
|
-
|
|
150
|
+
7. **Definition of Done + how we measure success.** Concrete, checkable acceptance criteria
|
|
127
151
|
(tests green, documented, metrics published, runbook) AND success metrics (p95,
|
|
128
152
|
cost ceiling, zero orphaned records). Each item must be verifiable, not a feeling.
|
|
129
|
-
|
|
153
|
+
8. **Dependencies.** Which other specs/systems/credentials this needs to exist first.
|
|
130
154
|
|
|
131
155
|
After each batch, reflect: "Decided: … / Still open: …". Move on only when the
|
|
132
156
|
current topic is closed.
|
|
@@ -146,7 +170,7 @@ have behavior, the scope has a boundary and the DoD is verifiable.") before Phas
|
|
|
146
170
|
## Phase B — Distill the artifacts
|
|
147
171
|
|
|
148
172
|
Only after convergence. Produce (and, if any new project-wide invariant surfaced in
|
|
149
|
-
step
|
|
173
|
+
step 5, append it to **`CONSTITUTION.md`** — never let an inviolable rule slip into an
|
|
150
174
|
ADR where it could later be "traded away"):
|
|
151
175
|
|
|
152
176
|
1. **One ADR per decision worth recording** at `docs/adr/ADR-NNN-<slug>.md`, format:
|
|
@@ -780,7 +780,9 @@ def _content_state(repo_path, repo_type, last_snapshot):
|
|
|
780
780
|
# verdict, or a report the record itself marked stale, is no anchor at all.
|
|
781
781
|
if ((snap.get("tests") or {}).get("freshness") or {}).get("status") == "stale":
|
|
782
782
|
return None
|
|
783
|
-
|
|
783
|
+
# the RECORD, not just the hash string: `_evidence_hash_matches` needs the `sha256_eol`
|
|
784
|
+
# marker beside it to know whether the compatibility comparison is still on offer
|
|
785
|
+
hashes = {r["path"]: r
|
|
784
786
|
for r in ((snap.get("tests") or {}).get("reports") or [])
|
|
785
787
|
if isinstance(r, dict) and r.get("path") and r.get("sha256")
|
|
786
788
|
and r.get("fresh_by") != "stale"}
|
|
@@ -814,7 +816,7 @@ def _report_fresh(repo_path, report_path, clock_fresh, content_state):
|
|
|
814
816
|
# cannot arise here, while normalizing one side would stop matching the recorded key.
|
|
815
817
|
rel = os.path.relpath(report_path, repo_path).replace("\\", "/")
|
|
816
818
|
recorded = content_state["hashes"].get(rel)
|
|
817
|
-
if
|
|
819
|
+
if _evidence_hash_matches(report_path, recorded) is not True:
|
|
818
820
|
return None
|
|
819
821
|
return "content"
|
|
820
822
|
|
|
@@ -842,7 +844,10 @@ def _test_evidence_provenance(repo_path, repo_type, last_snapshot=None):
|
|
|
842
844
|
"mtime_ns": mtime_ns,
|
|
843
845
|
"mtime": datetime.fromtimestamp(
|
|
844
846
|
mtime_ns / 1_000_000_000, timezone.utc).isoformat(),
|
|
845
|
-
"sha256":
|
|
847
|
+
"sha256": _sha256_evidence(path),
|
|
848
|
+
# the marker says HOW the hash above was taken, so a reader never has to guess
|
|
849
|
+
# (and 1.93.0-and-older records, which have no marker, keep their compatibility)
|
|
850
|
+
"sha256_eol": "lf",
|
|
846
851
|
})
|
|
847
852
|
if not reports:
|
|
848
853
|
status = "not-applicable" if repo_type == "flutter" else "missing"
|
|
@@ -5304,6 +5309,59 @@ def _sha256_file(path):
|
|
|
5304
5309
|
return None
|
|
5305
5310
|
|
|
5306
5311
|
|
|
5312
|
+
# kit 1.93.1: the hash of a TEXT evidence file, EOL-NORMALIZED (CRLF -> LF before hashing).
|
|
5313
|
+
#
|
|
5314
|
+
# A JUnit report is text, and a version control system is allowed to rewrite its line endings
|
|
5315
|
+
# on checkout -- `* text=auto eol=lf` is the recommended `.gitattributes` and the kit's own.
|
|
5316
|
+
# The suite that produced the report on Windows wrote CRLF, the repository stores LF, and a
|
|
5317
|
+
# clean checkout therefore yields a file that is byte-different and semantically identical.
|
|
5318
|
+
# The exact-byte hash read that as `evidence altered after ingest`: 1.93.0 shipped with the
|
|
5319
|
+
# limit merely NAMED in SPEC 4, and the release machine's own board hit it the same day.
|
|
5320
|
+
# Normalizing the ONE difference git is allowed to introduce keeps the guarantee that matters
|
|
5321
|
+
# -- any other changed byte (a swapped log, an edited count, a different run) still fails.
|
|
5322
|
+
#
|
|
5323
|
+
# `_sha256_file` is deliberately left alone: compile-validate hashes MANIFEST UNITS, where the
|
|
5324
|
+
# exact bytes ARE the claim, and a manifest that tolerated a rewrite would be tolerating the
|
|
5325
|
+
# thing it exists to detect.
|
|
5326
|
+
def _sha256_evidence(path):
|
|
5327
|
+
try:
|
|
5328
|
+
with open(path, "rb") as fh:
|
|
5329
|
+
data = fh.read()
|
|
5330
|
+
except OSError:
|
|
5331
|
+
return None
|
|
5332
|
+
return hashlib.sha256(data.replace(b"\r\n", b"\n")).hexdigest()
|
|
5333
|
+
|
|
5334
|
+
|
|
5335
|
+
def _evidence_hash_matches(path, record):
|
|
5336
|
+
"""Does the file on disk still hash to what the RECORD carries? True / False, or None when
|
|
5337
|
+
the record carries no hash at all (the caller decides: UNMEASURED, never a pass).
|
|
5338
|
+
|
|
5339
|
+
Records written by 1.93.1 and later carry `sha256_eol: "lf"` and are compared NORMALIZED,
|
|
5340
|
+
full stop. A record written BEFORE that marker hashed the file's exact bytes, whatever
|
|
5341
|
+
line endings the machine that ran the suite happened to write -- and 1.93.0's own release
|
|
5342
|
+
record is the proof: the suite wrote CRLF on Windows, git stored LF, and the LF checkout
|
|
5343
|
+
matched neither the recorded hash nor its normalized form, because the recording was of the
|
|
5344
|
+
CRLF RENDERING of the same text. So a pre-marker record is compared against all three
|
|
5345
|
+
renderings of the bytes on disk: normalized, exact, and CRLF. The door is narrow on purpose
|
|
5346
|
+
-- it opens only for records that predate the marker, and it admits only the line-ending
|
|
5347
|
+
renderings of THIS file's text: a report whose content really changed matches none of them."""
|
|
5348
|
+
want = record.get("sha256") if isinstance(record, dict) else None
|
|
5349
|
+
if not want:
|
|
5350
|
+
return None
|
|
5351
|
+
if _sha256_evidence(path) == want:
|
|
5352
|
+
return True
|
|
5353
|
+
if record.get("sha256_eol") == "lf":
|
|
5354
|
+
return False
|
|
5355
|
+
try:
|
|
5356
|
+
with open(path, "rb") as fh:
|
|
5357
|
+
data = fh.read()
|
|
5358
|
+
except OSError:
|
|
5359
|
+
return False
|
|
5360
|
+
lf = data.replace(b"\r\n", b"\n")
|
|
5361
|
+
return want in (hashlib.sha256(data).hexdigest(),
|
|
5362
|
+
hashlib.sha256(lf.replace(b"\n", b"\r\n")).hexdigest())
|
|
5363
|
+
|
|
5364
|
+
|
|
5307
5365
|
def _contained_unit(base, unit):
|
|
5308
5366
|
"""A compilation's units must be RELATIVE paths CONTAINED within the compilation
|
|
5309
5367
|
directory: the manifest references what was compiled, and what was compiled lives with
|
|
@@ -8348,6 +8406,10 @@ def cmd_dashboard(args):
|
|
|
8348
8406
|
for r in {e.get("repo") for e in ledger["fast_path"]}}
|
|
8349
8407
|
if ledger.get("spec_drift"):
|
|
8350
8408
|
out["spec_drift"] = ledger["spec_drift"]
|
|
8409
|
+
# lifecycle (ADR-040): reused VERBATIM from the readiness payload above (one read, no
|
|
8410
|
+
# drift possible between the two surfaces), conditional for the same reason spec_drift is.
|
|
8411
|
+
if rd.get("lifecycle"):
|
|
8412
|
+
out["lifecycle"] = rd["lifecycle"]
|
|
8351
8413
|
# evidence_origin: the latest snapshot's origin per repo, and ONLY when one exists --
|
|
8352
8414
|
# a ledger predating ADR-007 keeps the exact prior schema (same conditional-key rule
|
|
8353
8415
|
# fast_path and spec_drift already follow).
|
|
@@ -8989,7 +9051,7 @@ def _sealed_state(ledger, ledger_path):
|
|
|
8989
9051
|
elif not r.get("sha256"):
|
|
8990
9052
|
unmeasured.append("evidence hash unmeasured: %s — no hash recorded at ingest "
|
|
8991
9053
|
"(older snapshot, or the file was unreadable)" % rel)
|
|
8992
|
-
elif
|
|
9054
|
+
elif _evidence_hash_matches(full, r) is not True:
|
|
8993
9055
|
failures.append("evidence altered after ingest: %s" % rel)
|
|
8994
9056
|
|
|
8995
9057
|
out["reasons"] = failures + unmeasured
|
|
@@ -9496,6 +9558,12 @@ def cmd_readiness(args):
|
|
|
9496
9558
|
"gates": _gate_rollup(ledger),
|
|
9497
9559
|
"by_repo": repos,
|
|
9498
9560
|
}
|
|
9561
|
+
# lifecycle (ADR-040): advisory, and CONDITIONAL like fast_path/spec_drift -- a project
|
|
9562
|
+
# that declares no lifecycle: block keeps the exact prior payload and the exact prior
|
|
9563
|
+
# text. Speaking only when it matters is the anti-ceremony rule applied to itself.
|
|
9564
|
+
_lc = _lifecycle_for(os.path.dirname(os.path.abspath(args.ledger)) or os.getcwd())
|
|
9565
|
+
if _lc["declared"]:
|
|
9566
|
+
out["lifecycle"] = _lc
|
|
9499
9567
|
if args.json:
|
|
9500
9568
|
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
9501
9569
|
return
|
|
@@ -9576,6 +9644,8 @@ def cmd_readiness(args):
|
|
|
9576
9644
|
f"rising for {STALL_WINDOW} cycles: iterating more is not getting "
|
|
9577
9645
|
f"closer. Likely a design/SPEC problem — go back to the ADR / "
|
|
9578
9646
|
f"re-plan with the human (advisory)")
|
|
9647
|
+
if _lc["declared"]:
|
|
9648
|
+
print(" · " + _lifecycle_summary(_lc))
|
|
9579
9649
|
# rubrica (1.23.0): el ultimo grade por repo, siempre visible — guess
|
|
9580
9650
|
# estructurado que aconseja; si esta gateado ya bloqueo por el ledger
|
|
9581
9651
|
for rname, rnode in ledger["repos"].items():
|
|
@@ -10928,6 +10998,225 @@ _SC_SETEXT = re.compile(r"^(=+|-{2,})\s*$")
|
|
|
10928
10998
|
_SC_CHECKBOX = re.compile(r"^\[[ xX]\]\s*")
|
|
10929
10999
|
|
|
10930
11000
|
|
|
11001
|
+
# --------------------------------------------------------------------------- #
|
|
11002
|
+
# lifecycle (ADR-040: stack end-of-support vs go-live -- advisory, NEVER gates)
|
|
11003
|
+
# --------------------------------------------------------------------------- #
|
|
11004
|
+
# The method interrogates WHAT and WHY and has always treated the stack as a given
|
|
11005
|
+
# ("we use X"). A major line is not a decision: a minor line has an END-OF-SUPPORT
|
|
11006
|
+
# DATE, and a date that falls before the declared go-live is a forced upgrade nobody
|
|
11007
|
+
# planned. This dimension reads what the ADR CITES and compares it against the SPEC's
|
|
11008
|
+
# declared go-live. It cannot verify that the cited source tells the truth -- only that
|
|
11009
|
+
# a date and a source were cited at all. Advisory by construction, like spec-drift
|
|
11010
|
+
# (ADR-005): it never gates, never caps readiness, and never changes an exit code.
|
|
11011
|
+
_LC_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
11012
|
+
|
|
11013
|
+
|
|
11014
|
+
def _lc_valid_date(s):
|
|
11015
|
+
"""A cited date is real only if it is BOTH shaped YYYY-MM-DD and a calendar date:
|
|
11016
|
+
the format regex alone accepts 2025-13-40 (1.94.0 review), which then string-compares
|
|
11017
|
+
as if it were a real EOL. fromisoformat rejects the impossible month/day."""
|
|
11018
|
+
if not s or not _LC_DATE.match(s):
|
|
11019
|
+
return False
|
|
11020
|
+
try:
|
|
11021
|
+
datetime.strptime(s, "%Y-%m-%d")
|
|
11022
|
+
return True
|
|
11023
|
+
except ValueError:
|
|
11024
|
+
return False
|
|
11025
|
+
_LC_KEY = re.compile(r"^lifecycle\s*:\s*(.*)$")
|
|
11026
|
+
_LC_GOLIVE_FM = re.compile(r"^go[-_ ]?live\s*:\s*(.+?)\s*$", re.I)
|
|
11027
|
+
_LC_GOLIVE_LINE = re.compile(r"^\s*\*\*\s*go[-_ ]?live\s*:?\s*\*\*\s*:?\s*"
|
|
11028
|
+
r"(\d{4}-\d{2}-\d{2})\b", re.I)
|
|
11029
|
+
_LC_FIELDS = ("component", "version", "eol", "source", "checked")
|
|
11030
|
+
|
|
11031
|
+
|
|
11032
|
+
def _lc_frontmatter(lines):
|
|
11033
|
+
"""The lines INSIDE a leading `---` frontmatter block, or None when there is none.
|
|
11034
|
+
An unterminated fence is not data: a half-written block reads as absent."""
|
|
11035
|
+
if not lines or lines[0].strip() != "---":
|
|
11036
|
+
return None
|
|
11037
|
+
body = []
|
|
11038
|
+
for ln in lines[1:]:
|
|
11039
|
+
if ln.strip() == "---":
|
|
11040
|
+
return body
|
|
11041
|
+
body.append(ln)
|
|
11042
|
+
return None
|
|
11043
|
+
|
|
11044
|
+
|
|
11045
|
+
def _lc_parse(lines):
|
|
11046
|
+
"""The `lifecycle:` list-of-dicts of a markdown file, parsed as a YAML SUBSET --
|
|
11047
|
+
stdlib only, the kit ships no PyYAML. Returns None when the key is absent (nothing to
|
|
11048
|
+
measure) and [] when the key is there but nothing under it parses (a NAMED absence:
|
|
11049
|
+
the caller reports UNMEASURED with a reason, never a silent pass)."""
|
|
11050
|
+
fm = _lc_frontmatter(lines)
|
|
11051
|
+
if fm is None:
|
|
11052
|
+
return None
|
|
11053
|
+
entries, in_lc, cur = None, False, None
|
|
11054
|
+
for ln in fm:
|
|
11055
|
+
s = ln.strip()
|
|
11056
|
+
if not in_lc:
|
|
11057
|
+
m = _LC_KEY.match(ln)
|
|
11058
|
+
if m:
|
|
11059
|
+
entries = []
|
|
11060
|
+
# only the block form is supported; an inline `lifecycle: [...]` parses to
|
|
11061
|
+
# nothing and reads as unparseable, never as "this ADR fixes no component"
|
|
11062
|
+
in_lc = not m.group(1).strip()
|
|
11063
|
+
continue
|
|
11064
|
+
if not s or s.startswith("#"):
|
|
11065
|
+
continue
|
|
11066
|
+
if not ln[:1].isspace(): # back to column 0 -> the key ended
|
|
11067
|
+
in_lc = False
|
|
11068
|
+
continue
|
|
11069
|
+
if s.startswith("- "):
|
|
11070
|
+
cur = {}
|
|
11071
|
+
entries.append(cur)
|
|
11072
|
+
s = s[2:].strip()
|
|
11073
|
+
if not s:
|
|
11074
|
+
continue
|
|
11075
|
+
if cur is None:
|
|
11076
|
+
continue
|
|
11077
|
+
k, sep, v = s.partition(":")
|
|
11078
|
+
if not sep:
|
|
11079
|
+
continue
|
|
11080
|
+
k = k.strip().lower()
|
|
11081
|
+
if k in _LC_FIELDS:
|
|
11082
|
+
cur[k] = v.strip().strip("\x27\x22")
|
|
11083
|
+
if entries is not None:
|
|
11084
|
+
entries = [e for e in entries if e]
|
|
11085
|
+
return entries
|
|
11086
|
+
|
|
11087
|
+
|
|
11088
|
+
def _lc_go_live(text):
|
|
11089
|
+
"""The declared go-live of a SPEC: frontmatter `go_live: YYYY-MM-DD`, or a
|
|
11090
|
+
`**Go-live:** YYYY-MM-DD` line anywhere in the body. None = not declared."""
|
|
11091
|
+
lines = [ln.rstrip("\r") for ln in (text or "").split("\n")]
|
|
11092
|
+
fm = _lc_frontmatter(lines)
|
|
11093
|
+
if fm:
|
|
11094
|
+
for ln in fm:
|
|
11095
|
+
if ln[:1].isspace():
|
|
11096
|
+
continue
|
|
11097
|
+
m = _LC_GOLIVE_FM.match(ln.strip())
|
|
11098
|
+
if m:
|
|
11099
|
+
v = m.group(1).strip().strip("\x27\x22")
|
|
11100
|
+
if _lc_valid_date(v):
|
|
11101
|
+
return v
|
|
11102
|
+
for ln in lines:
|
|
11103
|
+
m = _LC_GOLIVE_LINE.match(ln)
|
|
11104
|
+
if m and _lc_valid_date(m.group(1)):
|
|
11105
|
+
return m.group(1)
|
|
11106
|
+
return None
|
|
11107
|
+
|
|
11108
|
+
|
|
11109
|
+
def _lc_short(path):
|
|
11110
|
+
"""The ADR dir as the reader would type it: relative to the cwd when that is shorter,
|
|
11111
|
+
absolute otherwise (a different drive on Windows has no relative form)."""
|
|
11112
|
+
if not path:
|
|
11113
|
+
return "docs/adr"
|
|
11114
|
+
try:
|
|
11115
|
+
rel = os.path.relpath(path)
|
|
11116
|
+
except ValueError:
|
|
11117
|
+
return path
|
|
11118
|
+
return rel if len(rel) < len(path) else path
|
|
11119
|
+
|
|
11120
|
+
|
|
11121
|
+
def _lifecycle_report(adr_dir, spec_text):
|
|
11122
|
+
"""Per-component verdicts for every `lifecycle:` entry under `adr_dir`, compared
|
|
11123
|
+
against the go-live declared in `spec_text`. Whole-dimension UNMEASURED -- with the
|
|
11124
|
+
reason spelled out, never silence -- when no ADR carries the block, when the block is
|
|
11125
|
+
unparseable, or when no go-live is declared."""
|
|
11126
|
+
go_live = _lc_go_live(spec_text)
|
|
11127
|
+
comps, unparsed, names = [], [], []
|
|
11128
|
+
if adr_dir and os.path.isdir(adr_dir):
|
|
11129
|
+
names = sorted(f for f in os.listdir(adr_dir) if f.lower().endswith(".md"))
|
|
11130
|
+
for fn in names:
|
|
11131
|
+
try:
|
|
11132
|
+
with open(os.path.join(adr_dir, fn), "r", encoding="utf-8",
|
|
11133
|
+
errors="replace") as fh:
|
|
11134
|
+
lines = [ln.rstrip("\r") for ln in fh.read().split("\n")]
|
|
11135
|
+
except OSError:
|
|
11136
|
+
continue
|
|
11137
|
+
ent = _lc_parse(lines)
|
|
11138
|
+
if ent is None:
|
|
11139
|
+
continue
|
|
11140
|
+
if not ent:
|
|
11141
|
+
unparsed.append(fn)
|
|
11142
|
+
continue
|
|
11143
|
+
for e in ent:
|
|
11144
|
+
comps.append({"adr": fn, "component": e.get("component") or "(unnamed)",
|
|
11145
|
+
"version": e.get("version"), "eol": e.get("eol"),
|
|
11146
|
+
"source": e.get("source"), "checked": e.get("checked")})
|
|
11147
|
+
out = {"status": "UNMEASURED", "reason": None, "go_live": go_live,
|
|
11148
|
+
"adr_dir": adr_dir, "declared": bool(comps or unparsed),
|
|
11149
|
+
"components": comps, "n_components": len(comps), "n_expiring": 0,
|
|
11150
|
+
"unparsed_adrs": unparsed}
|
|
11151
|
+
if not comps:
|
|
11152
|
+
out["reason"] = ("lifecycle: block declared but unparseable in: "
|
|
11153
|
+
+ ", ".join(unparsed) if unparsed else
|
|
11154
|
+
"no ADR under %s carries a lifecycle: block"
|
|
11155
|
+
% _lc_short(adr_dir))
|
|
11156
|
+
return out
|
|
11157
|
+
if not go_live:
|
|
11158
|
+
for c in comps:
|
|
11159
|
+
c["status"], c["detail"] = "unmeasured", "no go-live declared"
|
|
11160
|
+
out["reason"] = ("no go-live declared in the SPEC (frontmatter 'go_live: "
|
|
11161
|
+
"YYYY-MM-DD' or a '**Go-live:** YYYY-MM-DD' line)")
|
|
11162
|
+
return out
|
|
11163
|
+
for c in comps:
|
|
11164
|
+
eol = (c.get("eol") or "").strip()
|
|
11165
|
+
cited = (c.get("source") or "").strip()
|
|
11166
|
+
if not eol or eol.lower() == "unknown" or not _lc_valid_date(eol):
|
|
11167
|
+
c["status"] = "no EOL cited"
|
|
11168
|
+
c["detail"] = ("declared unknown" if eol.lower() == "unknown"
|
|
11169
|
+
else ("not a YYYY-MM-DD date: %s" % eol if eol else "absent"))
|
|
11170
|
+
elif eol < go_live:
|
|
11171
|
+
# the sharper fact wins the label: a date that already expires is reported as
|
|
11172
|
+
# expiring even when uncited, and the missing source stays visible in the record
|
|
11173
|
+
c["status"] = "expires before go-live"
|
|
11174
|
+
c["detail"] = "%s < %s" % (eol, go_live)
|
|
11175
|
+
out["n_expiring"] += 1
|
|
11176
|
+
elif not cited:
|
|
11177
|
+
c["status"] = "no source cited"
|
|
11178
|
+
c["detail"] = "eol %s is asserted, not cited" % eol
|
|
11179
|
+
else:
|
|
11180
|
+
c["status"] = "ok"
|
|
11181
|
+
c["detail"] = "eol %s >= go-live %s" % (eol, go_live)
|
|
11182
|
+
out["status"] = "measured"
|
|
11183
|
+
return out
|
|
11184
|
+
|
|
11185
|
+
|
|
11186
|
+
def _lifecycle_summary(lc):
|
|
11187
|
+
"""The one advisory line, shared by spec-check and readiness so the two surfaces
|
|
11188
|
+
cannot drift apart."""
|
|
11189
|
+
if lc["status"] != "measured":
|
|
11190
|
+
return "lifecycle: UNMEASURED - %s" % lc["reason"]
|
|
11191
|
+
return ("lifecycle: %d component(s), %d expire before go-live %s (advisory)"
|
|
11192
|
+
% (lc["n_components"], lc["n_expiring"], lc["go_live"]))
|
|
11193
|
+
|
|
11194
|
+
|
|
11195
|
+
def _lifecycle_root(*paths):
|
|
11196
|
+
"""The project root a lifecycle read hangs off: the directory of the first path
|
|
11197
|
+
given, else the cwd."""
|
|
11198
|
+
for p in paths:
|
|
11199
|
+
if p:
|
|
11200
|
+
return os.path.dirname(os.path.abspath(p)) or os.getcwd()
|
|
11201
|
+
return os.getcwd()
|
|
11202
|
+
|
|
11203
|
+
|
|
11204
|
+
def _lifecycle_for(root, adr_dir=None, spec_text=None, fallback=True):
|
|
11205
|
+
"""Read the dimension for a project root: `docs/adr` unless overridden, and the
|
|
11206
|
+
go-live from the text already in hand. `fallback` reads `<root>/SPEC.md` when no
|
|
11207
|
+
text was supplied -- it is OFF when the caller named the spec files itself, so an
|
|
11208
|
+
explicit `--spec` is never silently topped up from a file nobody asked for."""
|
|
11209
|
+
if fallback and not _lc_go_live(spec_text):
|
|
11210
|
+
sp = os.path.join(root, "SPEC.md")
|
|
11211
|
+
if os.path.isfile(sp):
|
|
11212
|
+
try:
|
|
11213
|
+
with open(sp, "r", encoding="utf-8", errors="replace") as fh:
|
|
11214
|
+
spec_text = (spec_text or "") + "\n" + fh.read()
|
|
11215
|
+
except OSError:
|
|
11216
|
+
pass
|
|
11217
|
+
return _lifecycle_report(adr_dir or os.path.join(root, "docs", "adr"), spec_text)
|
|
11218
|
+
|
|
11219
|
+
|
|
10931
11220
|
def _spec_check_text(text):
|
|
10932
11221
|
lines = text.split("\n")
|
|
10933
11222
|
n = len(lines)
|
|
@@ -11222,6 +11511,11 @@ def cmd_spec_check(args):
|
|
|
11222
11511
|
m = (_spec_check_text(text) if text.strip()
|
|
11223
11512
|
else {"blockers": [], "untestable": [], "stack_hits": [],
|
|
11224
11513
|
"non_ears": 0, "n_criteria": 0})
|
|
11514
|
+
# lifecycle (ADR-040): read-only and advisory -- it never touches `fail` below.
|
|
11515
|
+
lc = _lifecycle_for(_lifecycle_root(args.spec[0] if args.spec else None,
|
|
11516
|
+
args.acceptance),
|
|
11517
|
+
getattr(args, "adr_dir", None), text,
|
|
11518
|
+
fallback=not args.spec)
|
|
11225
11519
|
structural = len(m["blockers"]) + len(acc_block) # estructura = FACT -> bloquea
|
|
11226
11520
|
soft_find = len(m["untestable"]) + len(m["stack_hits"]) + len(acc_adv)
|
|
11227
11521
|
fail = structural > 0 or (args.strict and soft_find > 0)
|
|
@@ -11230,7 +11524,7 @@ def cmd_spec_check(args):
|
|
|
11230
11524
|
if args.json:
|
|
11231
11525
|
print(json.dumps({"verdict": verdict, "advisory": structural == 0,
|
|
11232
11526
|
"acceptance_blockers": acc_block,
|
|
11233
|
-
"acceptance_advisory": acc_adv, **m},
|
|
11527
|
+
"acceptance_advisory": acc_adv, "lifecycle": lc, **m},
|
|
11234
11528
|
indent=2, ensure_ascii=False))
|
|
11235
11529
|
sys.exit(1 if fail else 0)
|
|
11236
11530
|
|
|
@@ -11251,6 +11545,11 @@ def cmd_spec_check(args):
|
|
|
11251
11545
|
print(f" ~ stack named in criteria ({len(m['stack_hits'])}): {', '.join(terms[:6])} — the 'how' belongs in the ADR, not the SPEC")
|
|
11252
11546
|
if m["non_ears"] and m["n_criteria"]:
|
|
11253
11547
|
print(f" · advisory: {m['non_ears']}/{m['n_criteria']} criteria do not follow the EARS pattern (When/If/While … shall)")
|
|
11548
|
+
print(" · " + _lifecycle_summary(lc))
|
|
11549
|
+
for c in [x for x in lc["components"] if x.get("status") not in ("ok", None)][:5]:
|
|
11550
|
+
print(" %s %s %s - %s (%s)"
|
|
11551
|
+
% ("!" if c["status"] == "expires before go-live" else "~",
|
|
11552
|
+
c["component"], c.get("version") or "?", c["status"], c["detail"]))
|
|
11254
11553
|
print(" i consistency: INFERENTIAL (an uncorrelated checker), not this lint · "
|
|
11255
11554
|
"structure = FACT (blocks) · prose = advisory (--strict to gate)")
|
|
11256
11555
|
if verdict == "OK":
|
|
@@ -12471,6 +12770,9 @@ def build_parser():
|
|
|
12471
12770
|
help="validate ACCEPTANCE traceability (AC-n IDs): missing "
|
|
12472
12771
|
"file / zero criteria / zero traceable / duplicate "
|
|
12473
12772
|
"IDs block as structural FACTS")
|
|
12773
|
+
psc.add_argument("--adr-dir", default=None,
|
|
12774
|
+
help="ADRs whose lifecycle: blocks are read (ADR-040); default "
|
|
12775
|
+
"docs/adr beside the SPEC")
|
|
12474
12776
|
psc.add_argument("--strict", action="store_true",
|
|
12475
12777
|
help="also fail (exit 1) on soft findings: untestable criteria / stack named")
|
|
12476
12778
|
psc.add_argument("--json", action="store_true")
|
|
@@ -137,25 +137,49 @@ answer.
|
|
|
137
137
|
I deduce these core entities: … do they work for you, or is one missing?")
|
|
138
138
|
3. **Operation / API surface.** Propose the endpoints/operations and their
|
|
139
139
|
contracts (idempotency, status codes).
|
|
140
|
-
4. **
|
|
140
|
+
4. **Stack and lifecycle (MANDATORY — before any stack/architecture decision is
|
|
141
|
+
recorded).** The stack is not a given ("we use X"): it is a decision with an EXPIRY
|
|
142
|
+
DATE. A major version is a family; support is granted to a MINOR line, for a window,
|
|
143
|
+
by an upstream nobody in the room controls. Ask these one at a time, each with your
|
|
144
|
+
recommended answer, and **FETCH the dates from the official source AS YOU ASK — never
|
|
145
|
+
answer from memory**; record the URL and the day you checked:
|
|
146
|
+
(a) the EXACT version of every runtime/framework/store (JDK, web framework, ORM, DB,
|
|
147
|
+
Node, bundler, broker, cache) and its OSS/LTS end-of-support date, cited;
|
|
148
|
+
(b) support window vs the declared go-live AND the expected operating life — does the
|
|
149
|
+
line stay patched for the whole operation? If not, move it up BEFORE building: a major
|
|
150
|
+
upgrade days before the milestone is the most expensive one there is;
|
|
151
|
+
(c) major dependencies and the upgrade policy — who approves one, and when it is
|
|
152
|
+
scheduled (aligned with the dev-loop's "zero new dependencies without approval");
|
|
153
|
+
(d) development and observability tools the operator wants from day one (consoles, APM,
|
|
154
|
+
admin UIs) — they CONSTRAIN versions, so ask early or they force the upgrade;
|
|
155
|
+
(e) compatibility with reused legacy modules — the minimum version they support.
|
|
156
|
+
The answers distil into the stack ADR with the dates INSIDE it, as the machine-readable
|
|
157
|
+
`lifecycle:` frontmatter block (`component` / `version` / `eol` / `source` / `checked`,
|
|
158
|
+
ISO dates; `eol: unknown` is allowed and reads as a NAMED absence, never a pass) — see
|
|
159
|
+
`templates/docs/adr/ADR-stack-template.md`. The SPEC declares the milestone it is
|
|
160
|
+
compared against: frontmatter `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line.
|
|
161
|
+
`qa_ledger.py spec-check` then reports, per component, `ok` / `expires before go-live` /
|
|
162
|
+
`no EOL cited` / `no source cited` — ADVISORY, it never gates. It can see that a date
|
|
163
|
+
was CITED; it cannot see whether the citation is TRUE. That part is yours.
|
|
164
|
+
5. **Big decisions (→ ADR).** Propose 2–3 architecture options with trade-offs and
|
|
141
165
|
a recommended default: persistence, protocol, idempotency, sync/async, multi-tenancy.
|
|
142
|
-
|
|
166
|
+
6. **Behavior and dirty cases.** Happy path, then failures, retries, partial states,
|
|
143
167
|
concurrency, what must NOT happen.
|
|
144
|
-
|
|
168
|
+
7. **Inviolable constraints (→ `CONSTITUTION.md`).** Domain + security + operation
|
|
145
169
|
rules that can't be broken (money to the cent, no numbering gaps, never cross
|
|
146
170
|
environments, secrets never logged, auth). Write/extend `CONSTITUTION.md` with these —
|
|
147
171
|
one invariant per line, with a CWE reference where it maps. They feed the severity gate
|
|
148
172
|
downstream, and a breach is a BLOCKER, never a trade-off.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
8. **Out of scope.** Explicit boundaries with forward references.
|
|
174
|
+
9. **Acceptance / Definition of Done.** Concrete, checkable criteria + success metrics.
|
|
175
|
+
10. **Quality bar (→ config, kit 1.17.0).** "What level of quality is ENOUGH here, and which
|
|
152
176
|
dimensions are negotiable (coverage, perf, security)?" Propose thresholds fit for
|
|
153
177
|
the risk profile (a payments core is not an internal dashboard). What the human
|
|
154
178
|
declares goes into `uscha.config.json` (`defaults.coverage_threshold`,
|
|
155
179
|
`defaults.readiness_caps`, `defaults.simplicity`) — a declared threshold reads as
|
|
156
180
|
**requerimiento (config)** in the engine's output; an undeclared one stays a kit
|
|
157
181
|
default (opinion) and is labeled as such. Declaring is committing the config.
|
|
158
|
-
|
|
182
|
+
11. **Residual risks and dependencies.** What's uncertain, what must exist first.
|
|
159
183
|
For each HIGH-uncertainty risk, ask (kit 1.19.0, Tip 21 'Prototype to Learn'):
|
|
160
184
|
"Does it warrant a time-boxed spike before freezing the SPEC?" A spike runs on a
|
|
161
185
|
`spike/*` branch and its ONLY legitimate output is an **ADR with lessons**
|
|
@@ -98,6 +98,17 @@ enforcing the record is the engine's job. It is never resolved by "working aroun
|
|
|
98
98
|
- [ ] `golden-diff` clean (byte for byte) = hard closing condition of the touched module
|
|
99
99
|
- [ ] Corpus documented; a module with insufficient corpus = PARTIAL, never COVERS
|
|
100
100
|
|
|
101
|
+
## Stack lifecycle — the stack expires (non-negotiable)
|
|
102
|
+
|
|
103
|
+
> A major version is a family; support is granted to a MINOR line, for a window, by an upstream
|
|
104
|
+
> nobody here controls. A stack ADR that fixes a version without its end-of-support date has not
|
|
105
|
+
> decided anything — it has deferred the decision to the week before go-live (ADR-040).
|
|
106
|
+
> Measured — ADVISORY, never a gate — by `qa_ledger.py spec-check`'s lifecycle dimension.
|
|
107
|
+
|
|
108
|
+
- [ ] No stack ADR without a cited end-of-support date for every component it fixes <!-- ADR-040 -->
|
|
109
|
+
- [ ] The date comes from the OFFICIAL source, fetched when asked, with the URL and the day checked recorded — never from memory
|
|
110
|
+
- [ ] No component whose support ends before the declared `go_live` enters the build; the upgrade happens BEFORE, not days after
|
|
111
|
+
|
|
101
112
|
## Anti-ceremony — Lean over the method itself (meta-invariant)
|
|
102
113
|
|
|
103
114
|
> The risk is not a bad gate: it is the **sum** of good gates turning `/uscha-devloop` into an
|
|
@@ -139,6 +150,12 @@ enforcing the record is the engine's job. It is never resolved by "working aroun
|
|
|
139
150
|
- The **Golden (INV-GOLDEN-01)** invariant is measured by `qa_ledger.py golden-diff`: any `.received`
|
|
140
151
|
that does not match its `.approved` (or is unapproved) = **DIVERGE**, cutting the chain before judgment-day.
|
|
141
152
|
The agent does not touch `.approved` (ideally a `PreToolUse` hook makes it impossible).
|
|
153
|
+
- The **Stack lifecycle** invariant is measured by `qa_ledger.py spec-check`: each `lifecycle:`
|
|
154
|
+
entry in `docs/adr/*.md` is compared against the SPEC's `go_live` and reads `ok` / `expires
|
|
155
|
+
before go-live` / `no EOL cited` / `no source cited`, with the whole dimension UNMEASURED (and
|
|
156
|
+
the reason named) when nothing declares it. **Advisory** — it never gates and never caps
|
|
157
|
+
readiness: the engine can see that a date was CITED, never that the citation is true. Verifying
|
|
158
|
+
the source is the human's job; the record makes the omission visible.
|
|
142
159
|
- The **Anti-ceremony** meta-invariant is not measured by any subcommand: it is the admission filter
|
|
143
160
|
for new gates (does it autorun? does it stay quiet except when it matters? does it collapse into `readiness`? does a
|
|
144
161
|
trivial change skip it?). Its only mechanized leg today is the single verdict of `readiness` (kit
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
# The machine-readable half of a stack ADR (ADR-040). One entry per component this ADR
|
|
3
|
+
# FIXES: runtime, web framework, ORM, database, Node, bundler, message broker, cache.
|
|
4
|
+
#
|
|
5
|
+
# component the name as your team says it
|
|
6
|
+
# version the EXACT version or minor line you are fixing -- not the major family
|
|
7
|
+
# eol end of OSS/LTS support, YYYY-MM-DD. `unknown` is allowed and reads as a
|
|
8
|
+
# NAMED absence ("no EOL cited"), never as a pass
|
|
9
|
+
# source the OFFICIAL page the date came from. Fetch it while you ask; never
|
|
10
|
+
# answer from memory
|
|
11
|
+
# checked the day you actually looked, YYYY-MM-DD
|
|
12
|
+
#
|
|
13
|
+
# `qa_ledger.py spec-check` reads this block and compares each `eol` against the
|
|
14
|
+
# `go_live` declared in the SPEC. It is ADVISORY: it never gates and never caps
|
|
15
|
+
# readiness. It measures that a date and a source were CITED -- it cannot verify that
|
|
16
|
+
# the source tells the truth. That check is the human's.
|
|
17
|
+
lifecycle:
|
|
18
|
+
- component: <runtime, e.g. the language runtime>
|
|
19
|
+
version: "<exact version or minor line>"
|
|
20
|
+
eol: YYYY-MM-DD
|
|
21
|
+
source: https://<official support page>
|
|
22
|
+
checked: YYYY-MM-DD
|
|
23
|
+
- component: <web framework>
|
|
24
|
+
version: "<exact version or minor line>"
|
|
25
|
+
eol: YYYY-MM-DD
|
|
26
|
+
source: https://<official support page>
|
|
27
|
+
checked: YYYY-MM-DD
|
|
28
|
+
- component: <database / store>
|
|
29
|
+
version: "<exact version or minor line>"
|
|
30
|
+
eol: unknown
|
|
31
|
+
source: https://<official support page>
|
|
32
|
+
checked: YYYY-MM-DD
|
|
33
|
+
---
|
|
34
|
+
# ADR-NNN: <the stack this project fixes, and until when it is supported>
|
|
35
|
+
|
|
36
|
+
## Status: Accepted
|
|
37
|
+
|
|
38
|
+
> The SPEC must declare the milestone this block is compared against, as frontmatter
|
|
39
|
+
> `go_live: YYYY-MM-DD` or a `**Go-live:** YYYY-MM-DD` line. Without it the whole
|
|
40
|
+
> dimension reads UNMEASURED, with the reason named.
|
|
41
|
+
|
|
42
|
+
## Context
|
|
43
|
+
<Why this stack, and what already constrains it: reused legacy modules and the minimum
|
|
44
|
+
versions they support, the operator's development/observability tooling (consoles, APM,
|
|
45
|
+
admin UIs) — those constrain versions, so they are asked BEFORE the stack is fixed, not
|
|
46
|
+
after — and the expected operating life of the system beyond go-live.>
|
|
47
|
+
|
|
48
|
+
## Alternatives
|
|
49
|
+
- A) <option> — support window <until when>, cost <…>
|
|
50
|
+
- B) <option> — support window <until when>, cost <…>
|
|
51
|
+
|
|
52
|
+
## Decision
|
|
53
|
+
- <The exact versions fixed, one line each, with the date they stop being supported.>
|
|
54
|
+
- **Upgrade policy**: <who approves a major upgrade, and when it is scheduled>. A new
|
|
55
|
+
dependency is never added without explicit approval (the dev-loop's "zero new
|
|
56
|
+
dependencies without approval" rule).
|
|
57
|
+
|
|
58
|
+
## Reasons
|
|
59
|
+
- <why this line, and why its support window covers the operation, not just the launch>
|
|
60
|
+
|
|
61
|
+
## Consequences
|
|
62
|
+
+ <the good>
|
|
63
|
+
- <the cost: the upgrade already on the calendar, and who owns it>
|
|
64
|
+
|
|
65
|
+
## Implementation Plan
|
|
66
|
+
- Affected paths: <build files, lockfiles, CI images, base images>
|
|
67
|
+
- Tests: <the suite that proves the fixed versions actually run>
|
|
68
|
+
|
|
69
|
+
## Verification
|
|
70
|
+
- [ ] Every component this ADR fixes carries a `version`, an `eol` and the `source` it
|
|
71
|
+
was read from, checked on a named day.
|
|
72
|
+
- [ ] No component's `eol` falls before the SPEC's `go_live`; where one does, the upgrade
|
|
73
|
+
is scheduled BEFORE the build, not after.
|
|
@@ -8,6 +8,11 @@ Format: Status (proposed/accepted/experiment/deprecated/superseded) · Context
|
|
|
8
8
|
Decision · Consequences · Implementation Plan (affected paths, patterns, tests) ·
|
|
9
9
|
Verification (checkboxes).
|
|
10
10
|
|
|
11
|
+
The ADR that fixes the stack has a template of its own, `ADR-stack-template.md`: it carries a
|
|
12
|
+
machine-readable `lifecycle:` frontmatter block (component / version / eol / source / checked)
|
|
13
|
+
that `spec-check` compares against the SPEC's declared `go_live` (ADR-040). Advisory: it reports,
|
|
14
|
+
it never gates.
|
|
15
|
+
|
|
11
16
|
`Status: Experiment` is for a bounded, reversible hypothesis that needs real feedback.
|
|
12
17
|
It must include: Hypothesis, Feedback Signal, Review By or Review Trigger, Promote
|
|
13
18
|
Criteria, and Rollback / Supersede Criteria. Missing/expired metadata is advisory in
|