fabric drift: unregistered is computed over a watch set nothing reconciles against the cards the fabric already holds
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 42/100
Hướng nghiên cứu
Start with .agentic-framework/agents/fabric/lib/drift.sh and the new watchset_coverage.py; trace how drift expands watch patterns and discovers registered card locations. Then run the consumer-side fabricDriftWatchsetCoverage.test.js cases and inspect executed output, including the malformed-pattern control. Done means drift reports uncovered card locations and UNKNOWN on reader failure, with the summary updated and existing behavior preserved.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Context
- Framework version: 1.6.258
- Project: 050-email-archive (shared-tooling mode)
- Reported: 2026-09-25T09:27:16Z
Description
Problem
fw fabric drift section 1 computes:
unregistered = expand_patterns(watch-patterns.yaml) - card_locations
Both terms are bounded by the watch set, so the check can only ever produce a finding about a
file the glob already reaches. Nothing asks the converse — whether the watch set still reaches
everywhere the fabric already holds cards.
Where it does not, unregistered: 0 stops being evidence about the tree and becomes evidence
about the glob, and the report cannot tell those apart.
Measured (Pen / email-archive, 2026-09-25)
76 of 724 on-disk card locations (10.5%) lie outside the watch set, across 39 directories:
| kind | count | note |
|---|---|---|
.sh |
27 | no pattern covers scripts shell scripts at all |
.css |
27 | cards exist although a recorded decision says CSS gets no cards |
.js |
9 | incl. src/config/, scripts/lib/, scripts/tests/ |
| other | 13 | .service, .timer, .ps1, .html, .py, extensionless, and one dist/ build artifact the patterns explicitly exclude |
Demonstrated, not argued
src/config/ holds three files. Two have cards — so the project had already decided the
directory is in-fabric. The third, src/config/paths.js, has 38 importers and no card, and
no pattern covers src/config. Adding a recursive src/config JS glob:
unwatched 76 -> 74
unregistered 0 -> 1 naming src/config/paths.js
The 0 was false. Only the guard's input disagreed with the cards.
This is the same defect class as #70 (drift blind to card-vs-source divergence): the check reads
the wrong input and reports a confident 0.
Second, separate defect found while negative-controlling the fix
With a malformed watch-patterns.yaml, expand_patterns.py exits 2. Section 1 builds its
set-difference as comm -23 <(python3 expand_patterns.py ... 2>/dev/null | sort -u) <(registered):
stderr is discarded and a process substitution's exit status is invisible to comm, so a crashed
expander contributes an empty left-hand set and comm reports nothing missing.
Result: unregistered: 0 on a crashed reader. The new section, reading the same broken input,
reports UNKNOWN. Two sections of one report disagreeing about whether the input was readable.
This is the failure mode #70's own section-4 comment says "must not be reachable from here" — and
it is reachable from section 1, which predates that reasoning. Not fixed in this patch (one
structural change at a time); the fix is to capture the expander's exit status the way section 4
captures _div_rc. The orphaned/stale sections were not audited for the same hazard.
The patch
Adds a fifth section, Watch-set coverage, naming every directory where the fabric holds cards
the watch set never scans, and puts unwatched: N on the Summary line beside unregistered: N
so the 0 cannot be read alone.
Design notes:
- Reuses
expand_patterns.pyrather than reimplementing glob logic — the reason T-1842
centralised it: a second reader would be free to disagree with the section it reports on. - Fails loudly. Exits 2 when it cannot read its input; drift prints
UNKNOWN, never0. - Not a policy check. It does not decide which files deserve cards, and exit status is
untouched. Severity is left informational deliberately: the WARN-vs-ratchet question is still
open for section 4 and one change should not settle it for two sections at once. - A card whose file is absent from disk is not counted here — that is section 2 (orphaned).
Counting it would report one defect twice.
Negative controls (all observed)
| control | result |
|---|---|
A — drop a watched glob (src/database) |
section 5 names src/database{,/migrations,/models} (77 cards); unregistered stays 0, noticing nothing |
B — add the src/config glob |
unwatched 76→74, unregistered 0→1 naming paths.js |
C — corrupt watch-patterns.yaml |
expander exits 2; section 5 UNKNOWN, section 1 still 0 |
Consumer-side test (fabricDriftWatchsetCoverage.test.js, 9 cases) asserts on executed output,
never on a grep of drift.sh — a source grep matches the comment explaining a fix as readily as
the fix. Proven non-vacuous: reverting drift.sh turns the 3 repo-shape tests red and correctly
leaves the 5 hermetic ones green.
Diff
diff --git a/.agentic-framework/agents/fabric/lib/drift.sh b/.agentic-framework/agents/fabric/lib/drift.sh
index 60dda0f1..b7721dd1 100644
--- a/.agentic-framework/agents/fabric/lib/drift.sh
+++ b/.agentic-framework/agents/fabric/lib/drift.sh
@@ -254,7 +254,66 @@ $_div_unres detected import(s) whose target has no card)"
fi
echo ""
- echo -e "${BOLD}Summary:${NC} unregistered: $unregistered, orphaned: $orphaned, stale: $stale, divergent: $divergent ($_div_edges edge(s))"
+
+ # 5. Watch-set coverage — does the watch set still reach where the fabric has cards?
+ # T-2253 / G-FABRIC-WATCHSET-BLIND-TO-OWN-CARDS.
+ #
+ # Section 1 computes `unregistered = watched_files - card_locations`. Both terms are
+ # bounded by the watch set, so it can only ever find a file the glob already reaches.
+ # Nothing asked the converse. When the watch set does not cover a directory the fabric
+ # already holds cards for, `unregistered: 0` stops being evidence about the tree and
+ # becomes evidence about the glob — and the output cannot tell those apart.
+ #
+ # Measured here on 2026-09-25: 76 of 724 on-disk card locations (10.5%) across 39
+ # directories, including 27 registered shell scripts (`scripts/*.sh` has no pattern at
+ # all) and `src/config/`, where two of three files have cards and the third —
+ # `src/config/paths.js`, 38 importers — has none and could never be flagged.
+ #
+ # Deliberately NOT a policy check: it does not decide which files deserve cards. It
+ # reports only where the fabric's own cards prove a directory is in scope while the
+ # watch set disagrees. Severity is left informational on purpose — exit status is
+ # untouched — because the WARN-vs-ratchet question is still open for section 4 and one
+ # run should not settle it for two sections at once.
+ echo -e "${CYAN}Watch-set coverage (cards vs watch set):${NC}"
+ local unwatched=0 _wc_raw _wc_rc=0 _wc_dirs=0
+ # `|| _wc_rc=$?` for the reason section 4 documents: under `set -e` a failing command
+ # substitution aborts the function, and a detector that cannot run must never be
+ # indistinguishable from one that found nothing.
+ _wc_raw=$(python3 "$LIB_DIR/watchset_coverage.py" "$watch_file" "$PROJECT_ROOT" \
+ "$COMPONENTS_DIR" 2>/dev/null) || _wc_rc=$?
+ if [ "$_wc_rc" -ne 0 ] || ! grep -q '^##UNWATCHED_LOCATIONS=' <<< "$_wc_raw"; then
+ echo " ? UNKNOWN — the coverage reader did not run (watchset_coverage.py exited" \
+ "$_wc_rc). This is NOT 'fully covered'."
+ unwatched="UNKNOWN"
+ else
+ unwatched=$(sed -n 's/^##UNWATCHED_LOCATIONS=\([0-9]*\)##$/\1/p' <<< "$_wc_raw")
+ _wc_dirs=$(sed -n 's/^##UNWATCHED_DIRS=\([0-9]*\)##$/\1/p' <<< "$_wc_raw")
+ : "${unwatched:=0}" "${_wc_dirs:=0}"
+ local _wc_shown=0
+ while IFS= read -r _wc_line; do
+ case "$_wc_line" in
+ ' ! '*) ;;
+ *) continue ;;
+ esac
+ if [ "$_wc_shown" -lt 25 ]; then
+ echo "$_wc_line"
+ _wc_shown=$((_wc_shown + 1))
+ fi
+ done <<< "$_wc_raw"
+ if [ "$_wc_dirs" -gt "$_wc_shown" ]; then
+ echo " … $((_wc_dirs - _wc_shown)) more director(ies) (full list:" \
+ "python3 $LIB_DIR/watchset_coverage.py $watch_file $PROJECT_ROOT $COMPONENTS_DIR)"
+ fi
+ if [ "$unwatched" -eq 0 ]; then
+ echo " (none)"
+ else
+ echo " → $unwatched card location(s) in $_wc_dirs director(ies) the watch set" \
+ "never scans; section 1 cannot report on anything there."
+ fi
+ fi
+
+ echo ""
+ echo -e "${BOLD}Summary:${NC} unregistered: $unregistered, orphaned: $orphaned, stale: $stale, divergent: $divergent ($_div_edges edge(s)), unwatched: $unwatched"
if [ "$summary_flag" = "--summary" ]; then
echo "unregistered: $unregistered"
@@ -262,6 +321,8 @@ $_div_unres detected import(s) whose target has no card)"
echo "stale: $stale"
echo "divergent: $divergent"
echo "divergent_edges: $_div_edges"
+ echo "unwatched: $unwatched"
+ echo "unwatched_dirs: $_wc_dirs"
fi
return 0
diff --git a/.agentic-framework/agents/fabric/lib/watchset_coverage.py b/.agentic-framework/agents/fabric/lib/watchset_coverage.py
new file mode 100644
index 00000000..0cc74eaa
--- /dev/null
+++ b/.agentic-framework/agents/fabric/lib/watchset_coverage.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+# Fabric — watch-set coverage of the fabric's own registered surface (T-2253, Pen).
+#
+# WHAT THIS ANSWERS, AND WHY NOTHING ELSE DID
+#
+# drift.sh section 1 computes:
+#
+# unregistered = expand_patterns(watch-patterns.yaml) - card_locations
+#
+# i.e. "is there a watched file with no card". Every term on the right is bounded by the
+# watch set, so the check can only ever produce a finding about a file the watch set
+# already reaches. Nothing asks the converse — whether the watch set still reaches
+# everywhere the fabric ALREADY HOLDS CARDS. When it does not, `unregistered: 0` is not
+# evidence of a registered tree; it is evidence of a narrow glob, and the two are
+# indistinguishable in the output.
+#
+# Measured in Pen on 2026-09-25: 76 of 724 on-disk card locations (10.5%) lay outside the
+# watch set across 39 directories. Worked example — `src/config/` holds three files; two
+# have cards (so the project already decided the directory is in-fabric) and the third,
+# `src/config/paths.js` with 38 importers, has none. No pattern covers `src/config/**`, so
+# section 1 could never flag it, and reported 0. It surfaced only because T-2250's
+# divergence detector — a different reader — counted it among 37 anonymous "detected
+# import(s) whose target has no card".
+#
+# This is deliberately NOT a policy check. It does not decide which files deserve cards.
+# It reports only where the fabric's own cards prove a directory is in scope while the
+# watch set does not cover it, so the two inputs can no longer silently disagree.
+#
+# WHY IT SHELLS OUT TO expand_patterns.py
+#
+# Same reason T-1842 centralised that expansion in the first place: a second, independent
+# reimplementation of "what is watched" would be free to disagree with section 1, and a
+# coverage report that disagrees with the check it is reporting on is worse than none.
+# One reader, one answer. If that reader fails, this script fails loudly (exit 2) so the
+# caller can print UNKNOWN — a coverage checker that cannot read its input must never be
+# mistaken for one that found full coverage.
+#
+# Usage:
+# python3 watchset_coverage.py <watch-patterns.yaml> <project_root> <components_dir>
+#
+# Output: one " ! <dir> (<n> card(s))" line per uncovered directory, then sentinels
+# ##UNWATCHED_LOCATIONS=<n>##
+# ##UNWATCHED_DIRS=<n>##
+# Exit codes: 0 ok, 2 pattern expansion failed, 3 missing argv / unreadable components dir.
+
+from __future__ import annotations
+
+import collections
+import os
+import subprocess
+import sys
+
+
+def main() -> int:
+ if len(sys.argv) < 4:
+ sys.stderr.write(
+ "usage: watchset_coverage.py <watch-patterns.yaml> <project_root> "
+ "<components_dir>\n"
+ )
+ return 3
+
+ watch_file, project_root, components_dir = sys.argv[1], sys.argv[2], sys.argv[3]
+ lib_dir = os.path.dirname(os.path.abspath(__file__))
+
+ if not os.path.isdir(components_dir):
+ sys.stderr.write(f"watchset_coverage.py: no components dir: {components_dir}\n")
+ return 3
+
+ # Single source of truth for "what is watched" — see header.
+ try:
+ proc = subprocess.run(
+ [sys.executable, os.path.join(lib_dir, "expand_patterns.py"),
+ watch_file, project_root],
+ capture_output=True, text=True, timeout=120,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ sys.stderr.write(f"watchset_coverage.py: could not run expand_patterns.py: {exc}\n")
+ return 2
+ if proc.returncode != 0:
+ sys.stderr.write(
+ f"watchset_coverage.py: expand_patterns.py exited {proc.returncode}\n"
+ )
+ return 2
+
+ watched = {line.strip() for line in proc.stdout.splitlines() if line.strip()}
+
+ # Card locations. Read with the same `^location:` convention drift.sh uses so a card
+ # this script counts is a card that check counts.
+ locations = []
+ try:
+ for fn in sorted(os.listdir(components_dir)):
+ if not fn.endswith(".yaml"):
+ continue
+ try:
+ with open(os.path.join(components_dir, fn), "r", errors="replace") as fh:
+ for line in fh:
+ if line.startswith("location:"):
+ loc = line.split("location:", 1)[1].strip()
+ if loc:
+ locations.append(loc)
+ break
+ except OSError:
+ continue
+ except OSError as exc:
+ sys.stderr.write(f"watchset_coverage.py: cannot list {components_dir}: {exc}\n")
+ return 3
+
+ # A card whose file is absent from disk is drift's section 2 (orphaned), not ours.
+ # Counting it here would double-report one defect as two, the exact error T-2112 made
+ # on the Qdrant join key.
+ uncovered = collections.Counter()
+ total = 0
+ for loc in set(locations):
+ if loc in watched:
+ continue
+ if not os.path.isfile(os.path.join(project_root, loc)):
+ continue
+ total += 1
+ uncovered[os.path.dirname(loc) or "."] += 1
+
+ for directory, count in sorted(uncovered.items()):
+ print(f" ! {directory} ({count} card location(s) not covered)")
+
+ print(f"##UNWATCHED_LOCATIONS={total}##")
+ print(f"##UNWATCHED_DIRS={len(uncovered)}##")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
Reported via fw upstream report from field installation
- Ngôn ngữ chính
- Shell
- Star
- 16
- Fork
- 6
- Merge trung bình
- 6 giờ 24 phút
- Pull request đã merge (30 ngày)
- 5
Chuẩn bị môi trường
- Có Dockerfile hoặc tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của DimitriGeelen/agentic-engineering-framework
-
area:cli bug severity:low status:fixed-on-bleeding-edge triaged
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
DimitriGeelen/agentic-engineering-framework#100 · 5 bình luận ·
-
area:cli bug severity:medium status:fixed-on-bleeding-edge triaged
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
DimitriGeelen/agentic-engineering-framework#97 · 5 bình luận ·
-
area:context bug severity:low status:accepted triaged
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
DimitriGeelen/agentic-engineering-framework#96 · 3 bình luận ·
-
area:cli bug severity:medium status:accepted triaged
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
DimitriGeelen/agentic-engineering-framework#95 · 3 bình luận ·
-
area:watchtower bug severity:low status:released triaged
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
DimitriGeelen/agentic-engineering-framework#94 · 4 bình luận ·
Tất cả issue của DimitriGeelen/agentic-engineering-framework
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 83/100
Maintainer thường phản hồi trong vòng 1 ngày
-
bug github_actions security
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
DewaldOosthuizen/tech-cheat-sheets-and-notes#388 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
priority: low
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
melodic-software/claude-code-plugins#8831 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
-
Transparent bar text colour falls back to theme colour on animated GIFs and very dark backgroundsĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 86/100
Maintainer thường phản hồi trong vòng 2 ngày