aid-installer 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/bin/aid +162 -22
- package/bin/aid.ps1 +193 -43
- package/dashboard/home.html +41 -4
- package/dashboard/reader/derivation.py +228 -5
- package/dashboard/reader/models.py +22 -0
- package/dashboard/reader/parsers.py +127 -0
- package/dashboard/reader/reader.py +6 -1
- package/dashboard/server/reader.mjs +313 -0
- package/dashboard/server/server.py +12 -0
- package/lib/AidInstallCore.psm1 +247 -53
- package/lib/aid-install-core.sh +170 -13
- package/package.json +9 -6
- package/scripts/vendor.js +0 -98
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
# MIGRATION STATUS (task-013 M6 cutover audit):
|
|
17
17
|
#
|
|
18
18
|
# NORMALIZED (producer-emitted, feature-001 M1-M6 complete):
|
|
19
|
-
# - Running -- M4: aid-
|
|
19
|
+
# - Running -- M4: aid-describe, aid-define, aid-specify, aid-plan, aid-detail,
|
|
20
20
|
# aid-execute, aid-deploy (state-idle.md) emit at phase entry.
|
|
21
21
|
# - Paused-Awaiting-Input + Pause Reason -- M5: aid-specify (state-blocked.md,
|
|
22
22
|
# state-spike.md), aid-execute (state-delivery-gate.md),
|
|
23
|
-
# aid-
|
|
23
|
+
# aid-describe (state-completion.md) emit on pause transitions.
|
|
24
24
|
# - Blocked + Block Reason/Artifact -- M5: aid-execute (state-execute.md,
|
|
25
25
|
# state-review.md), aid-execute (state-delivery-gate.md)
|
|
26
26
|
# emit on impediment/failed-gate transitions.
|
|
@@ -60,7 +60,7 @@ from datetime import datetime, timezone
|
|
|
60
60
|
from pathlib import Path
|
|
61
61
|
from typing import Optional
|
|
62
62
|
|
|
63
|
-
from .models import KbBaseline, KbStatus, Lifecycle, PendingInput, SourceMode, TaskModel, TaskStatus
|
|
63
|
+
from .models import DocFreshness, KbBaseline, KbStatus, Lifecycle, PendingInput, SourceMode, TaskModel, TaskStatus
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
# ---------------------------------------------------------------------------
|
|
@@ -96,9 +96,10 @@ def _normalize_to_utc_ms(iso_str: str) -> Optional[int]:
|
|
|
96
96
|
# FF-A2: git freshness check (task-064, LC-A2 reader subprocess)
|
|
97
97
|
# ---------------------------------------------------------------------------
|
|
98
98
|
|
|
99
|
-
# Allowed git verbs: ONLY rev-parse, symbolic-ref, log (read-only)
|
|
99
|
+
# Allowed git verbs: ONLY rev-parse, symbolic-ref, log, merge-base (read-only)
|
|
100
|
+
# merge-base added task-042 (f007): --is-ancestor comparison for per-doc freshness.
|
|
100
101
|
# NEVER: fetch, pull, commit, checkout, reset, push, merge, rebase, add, rm
|
|
101
|
-
_GIT_ALLOWED_VERBS = frozenset({"rev-parse", "symbolic-ref", "log"})
|
|
102
|
+
_GIT_ALLOWED_VERBS = frozenset({"rev-parse", "symbolic-ref", "log", "merge-base"})
|
|
102
103
|
|
|
103
104
|
# Degradation timeout (seconds) -- bounded read, never blocks indefinitely
|
|
104
105
|
_GIT_TIMEOUT_S = 2
|
|
@@ -367,6 +368,228 @@ def derive_kb_status(
|
|
|
367
368
|
return KbStatus.unknown
|
|
368
369
|
|
|
369
370
|
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
# f007 / task-042: per-doc freshness read
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def derive_doc_freshness(kb_dir: Path, repo_root: Path) -> list[DocFreshness]:
|
|
376
|
+
"""f007: Per-doc freshness read for all hand-authored primary/extension KB docs.
|
|
377
|
+
|
|
378
|
+
Same algorithm as kb-freshness-check.sh (task-040):
|
|
379
|
+
- Same doc routing (skip INDEX.md, README.md, STATE.md, meta, generated)
|
|
380
|
+
- Same absence gate (no approved_at_commit: -> unknown; no/empty sources: -> current)
|
|
381
|
+
- Same git verbs: git-log -1 --format=%H -- <src> + merge-base --is-ancestor
|
|
382
|
+
- Same fold rule: suspect > current > unknown
|
|
383
|
+
- Same degrade-to-unknown matrix (any git failure -> unknown, never false suspect)
|
|
384
|
+
|
|
385
|
+
Returns list[DocFreshness] sorted by doc path (same deterministic order as script).
|
|
386
|
+
Never raises (NFR7). No writes. No LLM. Read-only git only.
|
|
387
|
+
|
|
388
|
+
merge-base is in _GIT_ALLOWED_VERBS (added task-042; still read-only).
|
|
389
|
+
"""
|
|
390
|
+
from .parsers import parse_doc_frontmatter, is_url_source
|
|
391
|
+
|
|
392
|
+
results: list[DocFreshness] = []
|
|
393
|
+
|
|
394
|
+
if not kb_dir.is_dir():
|
|
395
|
+
return results
|
|
396
|
+
|
|
397
|
+
# Collect docs: skip INDEX.md, README.md, STATE.md + meta + generated
|
|
398
|
+
# (same routing as build-kb-index.sh / lint-frontmatter.sh / kb-freshness-check.sh)
|
|
399
|
+
try:
|
|
400
|
+
candidate_paths = sorted(
|
|
401
|
+
p for p in kb_dir.iterdir()
|
|
402
|
+
if p.is_file() and p.suffix == ".md" and not p.name.startswith(".")
|
|
403
|
+
)
|
|
404
|
+
except OSError:
|
|
405
|
+
return results
|
|
406
|
+
|
|
407
|
+
# Always-skipped names
|
|
408
|
+
_SKIP_NAMES = {"INDEX.md", "README.md", "STATE.md"}
|
|
409
|
+
|
|
410
|
+
for doc_path in candidate_paths:
|
|
411
|
+
if doc_path.name in _SKIP_NAMES:
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
# Check routing fields: kb-category and source
|
|
415
|
+
# Re-use parse_doc_frontmatter but we need the routing fields too.
|
|
416
|
+
# Read routing separately via a quick line-scan (same tolerance posture).
|
|
417
|
+
kb_cat, src_field = _read_routing_fields(doc_path)
|
|
418
|
+
|
|
419
|
+
if kb_cat == "meta":
|
|
420
|
+
continue
|
|
421
|
+
if src_field == "generated":
|
|
422
|
+
continue
|
|
423
|
+
# Only primary and extension with hand-authored (or absent) source
|
|
424
|
+
if kb_cat not in ("primary", "extension", ""):
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
rel = doc_path.name # relative path within kb_dir (top-level only)
|
|
428
|
+
|
|
429
|
+
# Parse frontmatter: approved_at_commit + sources
|
|
430
|
+
approved_at_commit, sources_list, sources_field_present = parse_doc_frontmatter(doc_path)
|
|
431
|
+
|
|
432
|
+
# Absence gate: missing/empty approved_at_commit -> unknown (never suspect)
|
|
433
|
+
if not approved_at_commit:
|
|
434
|
+
results.append(DocFreshness(doc=rel, verdict="unknown", suspect_sources=[]))
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# sources: absent or empty -> current (nothing to drift against)
|
|
438
|
+
if not sources_field_present or not sources_list:
|
|
439
|
+
results.append(DocFreshness(doc=rel, verdict="current", suspect_sources=[]))
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
# Per-source staleness checks
|
|
443
|
+
n_current = 0
|
|
444
|
+
n_suspect = 0
|
|
445
|
+
n_unknown = 0
|
|
446
|
+
suspect_sources: list[str] = []
|
|
447
|
+
|
|
448
|
+
for entry in sources_list:
|
|
449
|
+
src_verdict = _check_source(entry, approved_at_commit, repo_root)
|
|
450
|
+
if src_verdict == "current":
|
|
451
|
+
n_current += 1
|
|
452
|
+
elif src_verdict == "suspect":
|
|
453
|
+
n_suspect += 1
|
|
454
|
+
suspect_sources.append(entry)
|
|
455
|
+
else:
|
|
456
|
+
n_unknown += 1
|
|
457
|
+
|
|
458
|
+
# Fold rule (identical to script)
|
|
459
|
+
if n_suspect > 0:
|
|
460
|
+
verdict = "suspect"
|
|
461
|
+
elif n_current > 0:
|
|
462
|
+
verdict = "current"
|
|
463
|
+
else:
|
|
464
|
+
verdict = "unknown"
|
|
465
|
+
|
|
466
|
+
results.append(DocFreshness(doc=rel, verdict=verdict, suspect_sources=suspect_sources))
|
|
467
|
+
|
|
468
|
+
return results
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _read_routing_fields(doc_path: Path) -> tuple[str, str]:
|
|
472
|
+
"""Read kb-category and source frontmatter scalars for routing.
|
|
473
|
+
|
|
474
|
+
Returns (kb_category, source_field). Absent fields return "".
|
|
475
|
+
Same degrade posture: any failure -> ("", "") -> treated as primary/hand-authored.
|
|
476
|
+
Never raises.
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
raw = doc_path.read_bytes()
|
|
480
|
+
text = raw.decode("utf-8", errors="replace")
|
|
481
|
+
except OSError:
|
|
482
|
+
return "", ""
|
|
483
|
+
|
|
484
|
+
kb_cat = ""
|
|
485
|
+
src_field = ""
|
|
486
|
+
in_fm = False
|
|
487
|
+
fm_entered = False
|
|
488
|
+
|
|
489
|
+
for line in text.splitlines():
|
|
490
|
+
stripped_line = line.rstrip()
|
|
491
|
+
if stripped_line == "---":
|
|
492
|
+
if not fm_entered:
|
|
493
|
+
in_fm = True
|
|
494
|
+
fm_entered = True
|
|
495
|
+
continue
|
|
496
|
+
else:
|
|
497
|
+
break
|
|
498
|
+
if not in_fm:
|
|
499
|
+
break
|
|
500
|
+
|
|
501
|
+
m = re.match(r"^kb-category:\s*(.*)", stripped_line)
|
|
502
|
+
if m:
|
|
503
|
+
kb_cat = m.group(1).strip().strip('"').strip("'")
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
m = re.match(r"^source:\s*(.*)", stripped_line)
|
|
507
|
+
if m:
|
|
508
|
+
src_field = m.group(1).strip().strip('"').strip("'")
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
return kb_cat, src_field
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _check_source(entry: str, approved_at_commit: str, repo_root: Path) -> str:
|
|
515
|
+
"""Check one sources: entry against approved_at_commit.
|
|
516
|
+
|
|
517
|
+
Returns "current" | "suspect" | "unknown".
|
|
518
|
+
Same algorithm as check_source() in kb-freshness-check.sh:
|
|
519
|
+
- URL -> unknown (cannot git log a URL)
|
|
520
|
+
- Path/glob -> git log -1 --format=%H -- <entry>; empty -> unknown
|
|
521
|
+
- Compare with merge-base --is-ancestor C_src <approved_at_commit>
|
|
522
|
+
exit 0 -> current, exit 1 -> suspect, other -> unknown (never false suspect)
|
|
523
|
+
|
|
524
|
+
Uses the existing _GIT_TIMEOUT_S bound (same 2s limit as other git calls).
|
|
525
|
+
Never raises; any failure degrades to unknown.
|
|
526
|
+
"""
|
|
527
|
+
from .parsers import is_url_source
|
|
528
|
+
|
|
529
|
+
# URL source -> unknown (cannot git log a URL)
|
|
530
|
+
if is_url_source(entry):
|
|
531
|
+
return "unknown"
|
|
532
|
+
|
|
533
|
+
# Path/glob source: get last-changed commit
|
|
534
|
+
c_src = _run_git_log_hash(repo_root, entry)
|
|
535
|
+
if c_src is None:
|
|
536
|
+
# Empty output (untracked / never existed) or git failure -> unknown
|
|
537
|
+
return "unknown"
|
|
538
|
+
|
|
539
|
+
# Compare: is C_src an ancestor of (or equal to) approved_at_commit?
|
|
540
|
+
return _run_merge_base_is_ancestor(repo_root, c_src, approved_at_commit)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _run_git_log_hash(repo_root: Path, pathspec: str) -> Optional[str]:
|
|
544
|
+
"""Run: git -C <repo_root> log -1 --format=%H -- <pathspec>
|
|
545
|
+
|
|
546
|
+
Returns the commit SHA on success, None on every failure mode.
|
|
547
|
+
Same subprocess pattern as _run_git_log (bounded, no-shell).
|
|
548
|
+
"""
|
|
549
|
+
try:
|
|
550
|
+
result = subprocess.run(
|
|
551
|
+
["git", "-C", str(repo_root), "log", "-1", "--format=%H", "--", pathspec],
|
|
552
|
+
capture_output=True,
|
|
553
|
+
text=True,
|
|
554
|
+
timeout=_GIT_TIMEOUT_S,
|
|
555
|
+
)
|
|
556
|
+
if result.returncode != 0:
|
|
557
|
+
return None
|
|
558
|
+
sha = result.stdout.strip()
|
|
559
|
+
return sha if sha else None
|
|
560
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _run_merge_base_is_ancestor(repo_root: Path, c_src: str, baseline: str) -> str:
|
|
565
|
+
"""Run: git -C <repo_root> merge-base --is-ancestor <c_src> <baseline>
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
"current" -- exit 0 (c_src is ancestor of baseline; source unchanged since approval)
|
|
569
|
+
"suspect" -- exit 1 (c_src is NOT ancestor; source changed after approval)
|
|
570
|
+
"unknown" -- any other exit code (bad object, git absent, timeout, etc.)
|
|
571
|
+
|
|
572
|
+
Uses merge-base verb which is now in _GIT_ALLOWED_VERBS (task-042).
|
|
573
|
+
Never raises; unexpected exit codes degrade to unknown (never a false suspect).
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
result = subprocess.run(
|
|
577
|
+
["git", "-C", str(repo_root), "merge-base", "--is-ancestor", c_src, baseline],
|
|
578
|
+
capture_output=True,
|
|
579
|
+
text=True,
|
|
580
|
+
timeout=_GIT_TIMEOUT_S,
|
|
581
|
+
)
|
|
582
|
+
if result.returncode == 0:
|
|
583
|
+
return "current"
|
|
584
|
+
elif result.returncode == 1:
|
|
585
|
+
return "suspect"
|
|
586
|
+
else:
|
|
587
|
+
# exit 128 (bad object), etc. -> unknown (never a false suspect)
|
|
588
|
+
return "unknown"
|
|
589
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
590
|
+
return "unknown"
|
|
591
|
+
|
|
592
|
+
|
|
370
593
|
# ---------------------------------------------------------------------------
|
|
371
594
|
# SM-2: derive_lifecycle -- unified preferred + fallback entry point
|
|
372
595
|
#
|
|
@@ -134,6 +134,21 @@ class KbBaseline:
|
|
|
134
134
|
tip_date: Optional[str] = None
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
@dataclass
|
|
138
|
+
class DocFreshness:
|
|
139
|
+
"""Per-doc freshness verdict (feature-007 f007, task-042).
|
|
140
|
+
|
|
141
|
+
Populated by derive_doc_freshness() in derivation.py; never persisted (NFR2).
|
|
142
|
+
|
|
143
|
+
doc: doc relative path under .aid/knowledge/ (e.g. "architecture.md")
|
|
144
|
+
verdict: "current" | "suspect" | "unknown"
|
|
145
|
+
suspect_sources: list of sources: entries that drifted (empty unless verdict=="suspect")
|
|
146
|
+
"""
|
|
147
|
+
doc: str
|
|
148
|
+
verdict: str # "current" | "suspect" | "unknown"
|
|
149
|
+
suspect_sources: list[str] = field(default_factory=list)
|
|
150
|
+
|
|
151
|
+
|
|
137
152
|
@dataclass
|
|
138
153
|
class KbStateRef:
|
|
139
154
|
"""KB state reference (feature-007 DM-A1 extended KbStateRef).
|
|
@@ -150,6 +165,10 @@ class KbStateRef:
|
|
|
150
165
|
status -- FR32 5-state KbStatus (derived, never persisted; NFR2)
|
|
151
166
|
summary_present -- True if <repo>/.aid/dashboard/kb.html exists (stat only)
|
|
152
167
|
kb_baseline -- {branch, tip_date} from .aid/settings.yml; None if unset/unparseable
|
|
168
|
+
|
|
169
|
+
New fields (feature-007 f007, task-042):
|
|
170
|
+
doc_freshness -- per-doc freshness list [{doc, verdict, suspect_sources}, ...]
|
|
171
|
+
suspect_count -- count of docs with verdict=="suspect" (badge rollup)
|
|
153
172
|
"""
|
|
154
173
|
summary_approved: bool
|
|
155
174
|
last_summary_date: Optional[str] = None # from STATE.md "User Approved: yes (YYYY-MM-DD...)"
|
|
@@ -158,6 +177,9 @@ class KbStateRef:
|
|
|
158
177
|
status: KbStatus = KbStatus.unknown # FR32 5-state derived status
|
|
159
178
|
summary_present: bool = False # True if .aid/dashboard/kb.html exists
|
|
160
179
|
kb_baseline: Optional[KbBaseline] = None # {branch, tip_date} or None
|
|
180
|
+
# feature-007 f007 new fields (task-042):
|
|
181
|
+
doc_freshness: list[DocFreshness] = field(default_factory=list) # per-doc verdicts
|
|
182
|
+
suspect_count: int = 0 # count of suspect docs (badge rollup)
|
|
161
183
|
|
|
162
184
|
|
|
163
185
|
@dataclass
|
|
@@ -21,6 +21,7 @@ from typing import Optional
|
|
|
21
21
|
from .models import (
|
|
22
22
|
DeliverableRef,
|
|
23
23
|
DeferredIssue,
|
|
24
|
+
DocFreshness,
|
|
24
25
|
FeatureRef,
|
|
25
26
|
Finding,
|
|
26
27
|
KbBaseline,
|
|
@@ -415,6 +416,132 @@ def _parse_kb_doc_count(text: str) -> Optional[int]:
|
|
|
415
416
|
return count if in_completeness else None
|
|
416
417
|
|
|
417
418
|
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
# f007 / task-042: per-doc frontmatter scan (sources: + approved_at_commit:)
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
_RE_FM_FENCE = re.compile(r"^---\s*$")
|
|
424
|
+
_RE_URL = re.compile(r"^[a-z][a-z0-9+.\-]*://")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def parse_doc_frontmatter(path: Path) -> tuple[Optional[str], list[str], bool]:
|
|
428
|
+
"""Tolerant sources:/approved_at_commit: frontmatter scan for one KB doc.
|
|
429
|
+
|
|
430
|
+
Reads only the YAML frontmatter block (between the first pair of '---' lines).
|
|
431
|
+
Identical algorithm to the bash fm_scalar/fm_list/fm_sources_present helpers in
|
|
432
|
+
kb-freshness-check.sh; mirrors the Node twin in reader.mjs (byte-parity).
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
(approved_at_commit, sources_list, sources_field_present)
|
|
436
|
+
|
|
437
|
+
approved_at_commit:
|
|
438
|
+
the trimmed scalar value, or None if absent/empty.
|
|
439
|
+
sources_list:
|
|
440
|
+
items from the sources: YAML list (inline or block); empty list if field
|
|
441
|
+
is absent, sources: [], or the value is not a list.
|
|
442
|
+
sources_field_present:
|
|
443
|
+
True if the sources: key was present (even as sources: []), False if absent.
|
|
444
|
+
Used to distinguish "sources: []" (-> current) from "no sources: field" (-> current too,
|
|
445
|
+
but noted separately for debugging; both map to current per the SPEC).
|
|
446
|
+
|
|
447
|
+
Never raises (NFR7). Handles:
|
|
448
|
+
- No frontmatter (no leading ---) -> (None, [], False)
|
|
449
|
+
- Inline list: sources: [a, b]
|
|
450
|
+
- Block list: sources:\n - a\n - b
|
|
451
|
+
- Empty list: sources: [] -> (approval, [], True)
|
|
452
|
+
"""
|
|
453
|
+
if not path.is_file():
|
|
454
|
+
return None, [], False
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
raw = path.read_bytes()
|
|
458
|
+
text = raw.decode("utf-8", errors="replace")
|
|
459
|
+
except OSError:
|
|
460
|
+
return None, [], False
|
|
461
|
+
|
|
462
|
+
approved_at_commit: Optional[str] = None
|
|
463
|
+
sources_list: list[str] = []
|
|
464
|
+
sources_field_present: bool = False
|
|
465
|
+
|
|
466
|
+
in_fm = False
|
|
467
|
+
fm_entered = False
|
|
468
|
+
in_sources_block = False
|
|
469
|
+
|
|
470
|
+
for line in text.splitlines():
|
|
471
|
+
if _RE_FM_FENCE.match(line):
|
|
472
|
+
if not fm_entered:
|
|
473
|
+
# Opening fence
|
|
474
|
+
in_fm = True
|
|
475
|
+
fm_entered = True
|
|
476
|
+
continue
|
|
477
|
+
else:
|
|
478
|
+
# Closing fence
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
if not in_fm:
|
|
482
|
+
# No opening fence yet -- not in frontmatter (or no frontmatter)
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
# Inside frontmatter block
|
|
486
|
+
stripped = line.rstrip()
|
|
487
|
+
|
|
488
|
+
if in_sources_block:
|
|
489
|
+
# Continuation of a block-style sources: list
|
|
490
|
+
m_item = re.match(r"^[ \t]+-[ \t]*(.*)", stripped)
|
|
491
|
+
if m_item:
|
|
492
|
+
item = m_item.group(1).strip().strip('"').strip("'")
|
|
493
|
+
if item:
|
|
494
|
+
sources_list.append(item)
|
|
495
|
+
continue
|
|
496
|
+
else:
|
|
497
|
+
# Any non-item line ends the block
|
|
498
|
+
in_sources_block = False
|
|
499
|
+
# Fall through to check this line for other fields
|
|
500
|
+
|
|
501
|
+
# approved_at_commit: scalar
|
|
502
|
+
m_aac = re.match(r"^approved_at_commit:\s*(.*)", stripped)
|
|
503
|
+
if m_aac:
|
|
504
|
+
val = m_aac.group(1).strip().strip('"').strip("'")
|
|
505
|
+
approved_at_commit = val if val else None
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# sources: field
|
|
509
|
+
m_src = re.match(r"^sources:\s*(.*)", stripped)
|
|
510
|
+
if m_src:
|
|
511
|
+
sources_field_present = True
|
|
512
|
+
rest = m_src.group(1).strip()
|
|
513
|
+
if rest == "[]":
|
|
514
|
+
# Explicit empty inline list: sources: []
|
|
515
|
+
# sources_field_present already set; list stays []
|
|
516
|
+
continue
|
|
517
|
+
if not rest:
|
|
518
|
+
# Bare 'sources:' with nothing after -- block list follows
|
|
519
|
+
in_sources_block = True
|
|
520
|
+
continue
|
|
521
|
+
if rest.startswith("["):
|
|
522
|
+
# Inline list: [a, b, c]
|
|
523
|
+
inner = rest.lstrip("[").rstrip("]").strip()
|
|
524
|
+
if inner:
|
|
525
|
+
for item in inner.split(","):
|
|
526
|
+
item = item.strip().strip('"').strip("'")
|
|
527
|
+
if item:
|
|
528
|
+
sources_list.append(item)
|
|
529
|
+
continue
|
|
530
|
+
# Block list: next indented lines are items
|
|
531
|
+
in_sources_block = True
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
return approved_at_commit, sources_list, sources_field_present
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def is_url_source(entry: str) -> bool:
|
|
538
|
+
"""Return True if entry matches a URL scheme (^[a-z][a-z0-9+.-]*://).
|
|
539
|
+
|
|
540
|
+
Identical to kb-freshness-check.sh is_url() and the Node twin isUrlSource().
|
|
541
|
+
"""
|
|
542
|
+
return bool(_RE_URL.match(entry))
|
|
543
|
+
|
|
544
|
+
|
|
418
545
|
# ---------------------------------------------------------------------------
|
|
419
546
|
# Prototype: REQUIREMENTS.md parser (work-overview header, delivery-002)
|
|
420
547
|
# ---------------------------------------------------------------------------
|
|
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from typing import Optional, Union
|
|
38
38
|
|
|
39
|
-
from .derivation import derive_kb_status
|
|
39
|
+
from .derivation import derive_doc_freshness, derive_kb_status
|
|
40
40
|
from .locator import enumerate_worktree_roots, locate_aid_root
|
|
41
41
|
from .models import (
|
|
42
42
|
DeferredIssue,
|
|
@@ -409,6 +409,11 @@ def _read_repo_full(
|
|
|
409
409
|
kb_state.status = kb_status
|
|
410
410
|
kb_state.kb_baseline = kb_baseline
|
|
411
411
|
|
|
412
|
+
# task-042: per-doc freshness (f007) -- additive; FF-A2 git_freshness_check retained
|
|
413
|
+
doc_freshness = derive_doc_freshness(kb_dir=loc.kb_dir, repo_root=root)
|
|
414
|
+
kb_state.doc_freshness = doc_freshness
|
|
415
|
+
kb_state.suspect_count = sum(1 for d in doc_freshness if d.verdict == "suspect")
|
|
416
|
+
|
|
412
417
|
repo_info = RepoInfo(
|
|
413
418
|
project_name=project_name,
|
|
414
419
|
aid_dir=str(loc.aid_dir),
|