fabric drift: unregistered is computed over a watch set nothing reconciles against the cards the fabric already holds
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 42/100
Línea de trabajo
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.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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
- Lenguaje dominante
- Shell
- Estrellas
- 16
- Forks
- 6
- Merge medio
- 6 h 24 min
- PR fusionados (30 d)
- 5
Preparar el entorno
- Incluye un Dockerfile o un archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de DimitriGeelen/agentic-engineering-framework
-
area:doctor bug severity:low status:accepted triaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
DimitriGeelen/agentic-engineering-framework#100 · 3 comentarios ·
-
area:doctor bug severity:medium status:accepted triaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
DimitriGeelen/agentic-engineering-framework#97 · 3 comentarios ·
-
area:context bug severity:low status:accepted triaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
DimitriGeelen/agentic-engineering-framework#96 · 3 comentarios ·
-
area:cli bug severity:medium status:accepted triaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
DimitriGeelen/agentic-engineering-framework#95 · 3 comentarios ·
-
area:watchtower bug severity:low status:released triaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
DimitriGeelen/agentic-engineering-framework#94 · 4 comentarios ·
Todos los issues de DimitriGeelen/agentic-engineering-framework
Issues similares
-
uno-testing: the step-by-step procedure serializes tool use (1.7 vs 2.3 tools/call)Posiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 67/100
unoplatform/studio#200 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 67/100
phusion/passenger-docker#480 ·
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 92/100
quickemu-project/quickemu#1963 ·
-
`repository` input doesn't workAbiertobug
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
-
source:reader type:enhancement
Dificultad 2/5 1-3 horas Aptitud para principiantes 67/100
stephrobert/blog-roadmap#95 ·