@appchy/jarvis 0.1.114 → 0.1.115

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.
@@ -191,3 +191,117 @@ def repairing(repo: Path, indent: str = " "):
191
191
  print(f"{indent}repaired {repaired} link(s) that pointed at what moved")
192
192
  for line in unresolved:
193
193
  print(f"{indent}COULD NOT PLACE {line}")
194
+
195
+
196
+ #: The three files that ARE a board item, so a folder holding one is addressable by
197
+ #: its name. Names are globally unique across tasks, epics and versions, which is what
198
+ #: makes a name a usable second resolver when a path has stopped working.
199
+ _ITEM_FILES = ("task.md", "epic.md", "version.md")
200
+
201
+
202
+ def _index(base: Path) -> dict:
203
+ """Board item folders by name. A name with two homes is left out rather than guessed."""
204
+ homes: dict = {}
205
+ for doc in base.rglob("*.md"):
206
+ if SKIP & set(doc.parts) or doc.name not in _ITEM_FILES:
207
+ continue
208
+ homes.setdefault(doc.parent.name, set()).add(doc.parent.resolve())
209
+ return {name: next(iter(dirs)) for name, dirs in homes.items() if len(dirs) == 1}
210
+
211
+
212
+ def _settle(doc: Path, href: str, index: dict):
213
+ """Where a broken link meant to point, or None when that is a guess.
214
+
215
+ Two resolvers, and both must land on exactly one file. **Depth first**: the tail
216
+ is right and the `../` count is wrong, which is what a file written at one tier
217
+ and moved to another leaves behind. **Then by name**: the item moved bucket or
218
+ epic, so no `../` count reaches it, but the folder it lives in is named in the
219
+ link and a board name has one home. Anything either resolver finds twice, or
220
+ neither finds at all, is reported for a person — a link repaired to the wrong
221
+ real file is worse than one that is visibly broken.
222
+ """
223
+ path = href.split("#")[0]
224
+ # Only the leading `./` and `../` hops come off. `lstrip("./")` would take the
225
+ # dot of `.github` with them and then resolve nothing, which is a link reported
226
+ # as unfixable because the checker damaged it on the way in.
227
+ tail = _HOPS.sub("", path)
228
+ hits = {c.resolve() for n in range(10)
229
+ if (c := doc.parent / ("../" * n) / tail).exists()}
230
+ if len(hits) == 1:
231
+ return next(iter(hits))
232
+
233
+ parts = [p for p in path.split("/") if p not in ("..", ".", "")]
234
+ if len(parts) >= 2 and (home := index.get(parts[-2])) is not None:
235
+ want = home / parts[-1]
236
+ if want.exists():
237
+ return want.resolve()
238
+ return None
239
+
240
+
241
+ #: An inline code span. A doc that TEACHES the link syntax writes `](../…md)` inside
242
+ #: backticks, and a checker that reads it as a link reports a file nobody ever meant
243
+ #: to exist. Same shape of suppression the governance lints use, and the same reason:
244
+ #: a blocking check's whole cost is its false positives.
245
+ #:
246
+ #: It crosses newlines because a quoted anchor wraps like any other prose, and the
247
+ #: one live false positive this check ever produced was a span that opened on one
248
+ #: line and closed on the next. Bounded rather than greedy: an unbalanced backtick
249
+ #: somewhere in a long document must not swallow the links after it.
250
+ CODE_SPAN = re.compile(r"`[^`]{0,500}`", re.S)
251
+
252
+ #: Leading `./` and `../` hops, and nothing else.
253
+ _HOPS = re.compile(r"^(?:\.{1,2}/)+")
254
+
255
+
256
+ def dangling(base: Path) -> list:
257
+ """Every relative markdown link under `base` that resolves to nothing.
258
+
259
+ Returns `(doc, href, fix_or_None)` per broken link. This is the set `snapshot()`
260
+ records and `repair()` refuses on purpose — a link broken BEFORE a move has no
261
+ before-state to be repaired by identity, so it is settled from its own text or
262
+ not at all.
263
+ """
264
+ index = _index(base)
265
+ out = []
266
+ for doc in sorted(_docs(base)):
267
+ try:
268
+ text = doc.read_text(errors="ignore")
269
+ except OSError:
270
+ continue
271
+ text = CODE_SPAN.sub("", text)
272
+ for href in dict.fromkeys(m.group(1) for m in LINK.finditer(text)):
273
+ if "://" in href or href.startswith(("#", "mailto:", "/")):
274
+ continue
275
+ if (doc.parent / href.split("#")[0]).exists():
276
+ continue
277
+ out.append((doc, href, _settle(doc, href, index)))
278
+ return out
279
+
280
+
281
+ def repair_dangling(base: Path, apply: bool = False) -> tuple:
282
+ """Point every settled broken link at what it names. Returns (fixed, stuck).
283
+
284
+ Rewrites on the link's own syntax, never on the bare path, so a path that also
285
+ appears in a sentence is left alone — the same anchoring `repair()` uses, for the
286
+ same reason.
287
+ """
288
+ fixed, stuck = 0, []
289
+ edits: dict = {}
290
+ for doc, href, target in dangling(base):
291
+ if target is None:
292
+ stuck.append((doc, href))
293
+ continue
294
+ _, _, anchor = href.partition("#")
295
+ new = os.path.relpath(target, doc.parent) + (f"#{anchor}" if anchor else "")
296
+ if new == href:
297
+ stuck.append((doc, href))
298
+ continue
299
+ edits.setdefault(doc, []).append((href, new))
300
+ fixed += 1
301
+ if apply:
302
+ for doc, pairs in edits.items():
303
+ text = doc.read_text(errors="ignore")
304
+ for href, new in pairs:
305
+ text = text.replace(f"]({href})", f"]({new})")
306
+ doc.write_text(text)
307
+ return fixed, stuck
@@ -165,6 +165,50 @@ def misalignments(root: Path) -> list:
165
165
  + _align_agents(root) + _align_retired(root) + _align_acceptance(root))
166
166
 
167
167
 
168
+ #: A cut that has shipped, and the archive it ends up in. Their links are reported
169
+ #: and never blocking: a plan naming source that has since been deleted, or a
170
+ #: `handoff.md` that `archive` stripped, is an accurate record of a world that is
171
+ #: gone. Demanding those resolve would make the gate unsatisfiable for a reason
172
+ #: nobody could act on.
173
+ _SHIPPED = ("versions/complete/", "archive/")
174
+
175
+
176
+ def cmd_links(args) -> int:
177
+ """Board links that point at nothing. Exits 1 on a LIVE one, so a gate can block.
178
+
179
+ Blocking is safe here in a way it is not for the prose checks next door: a link
180
+ either resolves or it does not, there is no sentence to argue with, and the steady
181
+ state is zero because every folder move repairs its own links. A red therefore
182
+ means somebody hand-wrote a path, which is a ten-second fix — not a judgement call
183
+ a reader has to relitigate.
184
+
185
+ `--fix` repairs what is settled and leaves the rest. It is the only write, and it
186
+ is refused nothing: repairing a link that resolves to exactly one file cannot lose
187
+ anything, because the old path resolved to no file at all.
188
+ """
189
+ from . import links as links_mod
190
+ root = find_work_root()
191
+ if args.get("fix"):
192
+ fixed, _ = links_mod.repair_dangling(root, apply=True)
193
+ print(f" repaired {fixed} link(s)")
194
+
195
+ broken = links_mod.dangling(root)
196
+ live = [(d, h) for d, h, _ in broken
197
+ if not any(m in str(d) for m in _SHIPPED)]
198
+ shipped = len(broken) - len(live)
199
+
200
+ for doc, href in live:
201
+ print(f" ✗ {doc.relative_to(root.parent)} -> {href}")
202
+ if shipped:
203
+ print(f" · {shipped} more in cuts that have shipped — reported, never blocking")
204
+ if not live:
205
+ print(" every link in a live brief or governance file resolves")
206
+ return 0
207
+ print(f"\n {len(live)} broken link(s) in live files · exit 1 "
208
+ f"(`jarvis work links --fix` repairs what it can settle)")
209
+ return 1
210
+
211
+
168
212
  def cmd_align(args) -> int:
169
213
  """Report every misalignment class. REPORT-ONLY, exit 0 — by founder call.
170
214
 
@@ -2076,6 +2076,103 @@ def test_a_finished_cut_leaves_the_board_without_pretending_it_shipped():
2076
2076
  os.environ.pop("WORK_DIR", None)
2077
2077
 
2078
2078
 
2079
+ def test_a_standing_broken_link_is_settled_by_depth_or_by_board_name():
2080
+ # The set `repair()` refuses on purpose: broken BEFORE any move, so there is no
2081
+ # before-state and identity cannot answer. Two resolvers instead — the tail is
2082
+ # right and the `../` count is wrong, or the item moved bucket and the folder it
2083
+ # lives in is named in the link.
2084
+ from harness import links
2085
+ with tempfile.TemporaryDirectory() as tmp:
2086
+ base = Path(tmp) / "work"
2087
+ (base / "product").mkdir(parents=True)
2088
+ (base / "product" / "board.md").write_text("# board\n")
2089
+ moved = base / "versions" / "a-cut" / "an-epic" / "complete" / "shipped-thing"
2090
+ moved.mkdir(parents=True)
2091
+ (moved / "task.md").write_text("# shipped thing\n")
2092
+
2093
+ doc = base / "versions" / "a-cut" / "an-epic" / "queue" / "live-one" / "task.md"
2094
+ doc.parent.mkdir(parents=True)
2095
+ doc.write_text(
2096
+ # two hops short of product/
2097
+ "governed by [board](../../../product/board.md), "
2098
+ # names a bucket the item has left
2099
+ "after [shipped](../shipped-thing/task.md).\n")
2100
+
2101
+ fixed, stuck = links.repair_dangling(base, apply=True)
2102
+ assert (fixed, stuck) == (2, []), (fixed, stuck)
2103
+ body = doc.read_text()
2104
+ assert "](../../../../../product/board.md)" in body, body
2105
+ assert "](../../complete/shipped-thing/task.md)" in body, body
2106
+ assert links.dangling(base) == []
2107
+
2108
+
2109
+ def test_a_broken_link_two_resolvers_disagree_about_is_left_for_a_person():
2110
+ # A link repaired to the wrong real file is worse than one that is visibly
2111
+ # broken: nothing afterwards can tell it was ever wrong.
2112
+ from harness import links
2113
+ with tempfile.TemporaryDirectory() as tmp:
2114
+ base = Path(tmp) / "work"
2115
+ # The SAME tail exists at two heights on one walk up, so counting `../`
2116
+ # cannot choose between them.
2117
+ (base / "notes").mkdir(parents=True)
2118
+ (base / "notes" / "shared.md").write_text("# the outer one\n")
2119
+ (base / "one" / "notes").mkdir(parents=True)
2120
+ (base / "one" / "notes" / "shared.md").write_text("# the inner one\n")
2121
+ doc = base / "one" / "notes" / "deep" / "doc.md"
2122
+ doc.parent.mkdir(parents=True)
2123
+ doc.write_text("see [it](../../../../notes/shared.md)\n")
2124
+
2125
+ fixed, stuck = links.repair_dangling(base, apply=True)
2126
+ assert fixed == 0, "a link two candidates answer must not be rewritten"
2127
+ assert [h for _, h in stuck] == ["../../../../notes/shared.md"]
2128
+ assert "](../../../../notes/shared.md)" in doc.read_text(), "it was rewritten"
2129
+
2130
+
2131
+ def test_a_link_inside_a_code_span_is_prose_and_never_a_finding():
2132
+ # A doc teaching the syntax writes `](../…md)` in backticks. The live false
2133
+ # positive this check ever produced was a quoted anchor whose span opened on one
2134
+ # line and closed on the next, so the span has to cross newlines.
2135
+ from harness import links
2136
+ with tempfile.TemporaryDirectory() as tmp:
2137
+ base = Path(tmp) / "work"
2138
+ base.mkdir(parents=True)
2139
+ (base / "doc.md").write_text(
2140
+ "Resolving every `](../…md)` in the tree.\n\n"
2141
+ "The anchor reads `[sessions](sessions.md)'s AC-03 and the test\n"
2142
+ "lands later`, which is a quotation.\n\n"
2143
+ "But [this one](../really-gone.md) is a link.\n")
2144
+
2145
+ broken = links.dangling(base)
2146
+ assert [h for _, h, _ in broken] == ["../really-gone.md"], broken
2147
+
2148
+
2149
+ def test_the_links_gate_blocks_on_a_live_brief_and_never_on_a_shipped_cut():
2150
+ # Blocking is only safe because the steady state is zero — every folder move
2151
+ # repairs its own links — and because a shipped cut is allowed to name things
2152
+ # that have since been deleted.
2153
+ import contextlib, io
2154
+ from harness import report
2155
+ with tempfile.TemporaryDirectory() as tmp:
2156
+ root = Path(tmp) / "work"
2157
+ shipped = root / "versions" / "complete" / "01-done" / "an-epic"
2158
+ shipped.mkdir(parents=True)
2159
+ (shipped / "epic.md").write_text("gone: [src](../../../../src/deleted.ts)\n")
2160
+ os.environ["WORK_DIR"] = str(root)
2161
+ try:
2162
+ with contextlib.redirect_stdout(io.StringIO()) as out:
2163
+ assert report.cmd_links({}) == 0, "a shipped cut must not block"
2164
+ assert "never blocking" in out.getvalue()
2165
+
2166
+ live = root / "versions" / "02-open" / "an-epic" / "queue" / "a-task"
2167
+ live.mkdir(parents=True)
2168
+ (live / "task.md").write_text("see [rule](../../../../nowhere.md)\n")
2169
+ with contextlib.redirect_stdout(io.StringIO()) as out:
2170
+ assert report.cmd_links({}) == 1, "a live broken link must block"
2171
+ assert "nowhere.md" in out.getvalue()
2172
+ finally:
2173
+ os.environ.pop("WORK_DIR", None)
2174
+
2175
+
2079
2176
  def test_links_are_repaired_by_what_they_pointed_at_not_by_what_they_say():
2080
2177
  from harness import links
2081
2178
  with tempfile.TemporaryDirectory() as tmp:
package/harness/work.py CHANGED
@@ -77,6 +77,9 @@ Subcommands:
77
77
  list --branch <ref> the board as another branch has it (read-only)
78
78
  rules --task <name> what constrains a task, derived
79
79
  align [--class <name>] every misalignment class; REPORT-ONLY, exit 0
80
+ links [--fix] board links that point at nothing. Exits 1 when a
81
+ LIVE brief or governance file holds one, so a gate
82
+ can block; history is reported and never blocks
80
83
  wrap [--task <name>] finish a session cleanly: what a machine knows
81
84
  about this run, then what to do. Writes NOTHING
82
85
  coverage [--feature <name>] what a RUN proved vs what features promise
@@ -143,7 +146,7 @@ from harness import ids
143
146
  from harness.architecture import (cmd_domain_new, cmd_init, cmd_rules, cmd_system_new,
144
147
  cmd_where)
145
148
  from harness.coverage import cmd_coverage
146
- from harness.report import cmd_align, cmd_list, cmd_readme
149
+ from harness.report import cmd_align, cmd_links, cmd_list, cmd_readme
147
150
  from harness.wrap import cmd_wrap
148
151
  from harness.config import (DEFAULTS, ConfigError, apply, cmd_applies, cmd_config,
149
152
  cmd_context, cmd_method, cmd_remind, resolve)
@@ -167,7 +170,7 @@ SUBCOMMANDS = (
167
170
  "find", "list", "readme", "move", "plan", "session", "kickoff", "path", "code",
168
171
  "domain-new", "system-new", "where", "rules", "align", "wrap", "coverage",
169
172
  "migrate", "next", "status", "drop", "ask", "answer", "needs", "verify",
170
- "observed", "log", "digest", "sync", "check",
173
+ "observed", "log", "digest", "sync", "check", "links",
171
174
  )
172
175
 
173
176
 
@@ -350,7 +353,7 @@ def _say(note: str) -> None:
350
353
  #: remembering to come back here.
351
354
  _READS = {"list", "find", "path", "code", "where", "rules", "status", "digest",
352
355
  "log", "needs", "align", "coverage", "readme", "config", "context",
353
- "doctor", "kickoff", "remind", "applies", "wrap"}
356
+ "doctor", "kickoff", "remind", "applies", "wrap", "links"}
354
357
 
355
358
 
356
359
  def _reads_only(cmd: str, flags: dict) -> None:
@@ -514,6 +517,8 @@ def dispatch(cmd, pos, flags, cfg) -> int:
514
517
  return cmd_rules({**flags, "task": flags.get("task") or (pos[0] if pos else None)})
515
518
  if cmd == "align":
516
519
  return cmd_align(flags)
520
+ if cmd == "links":
521
+ return cmd_links(flags)
517
522
  if cmd == "wrap":
518
523
  return cmd_wrap(cfg, flags, _project_root(flags))
519
524
  if cmd == "coverage":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.114",
3
+ "version": "0.1.115",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -57,16 +57,16 @@
57
57
  "typescript": "^5.7.0",
58
58
  "vitest": "^2.1.0",
59
59
  "@jarvis/agents": "1.0.0",
60
+ "@jarvis/anthropic": "1.0.0",
60
61
  "@jarvis/board": "0.1.0",
61
62
  "@jarvis/data": "0.1.0",
62
63
  "@jarvis/errors": "1.0.0",
63
64
  "@jarvis/logger": "1.0.0",
64
- "@jarvis/rpc": "1.0.0",
65
- "@jarvis/anthropic": "1.0.0",
66
65
  "@jarvis/types": "1.0.0",
66
+ "@jarvis/rpc": "1.0.0",
67
67
  "@jarvis/typescript-config": "1.0.0",
68
- "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/ui": "0.1.0"
68
+ "@jarvis/ui": "0.1.0",
69
+ "@jarvis/vitest-config": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",