jui-tools-mcp-server 2.10.3 → 2.11.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.
@@ -297,7 +297,7 @@
297
297
  },
298
298
  "diagram": {
299
299
  "description": "The screen-transition diagram is derived from the same ids.",
300
- "nodeSource": "flow test inline step 'screen' values, plus file-reference steps normalized to the same id space",
300
+ "nodeSource": "TWO sources, both normalized into the same id space: (1) flow test inline step 'screen' values plus file-reference steps; (2) spec 'transitions[].destination' values, through specTransitions below. Naming only one of them here is how an implementation ends up reading a source the canon does not declare.",
301
301
  "pipeline": [
302
302
  "resolve",
303
303
  "canonicalize",
@@ -309,6 +309,53 @@
309
309
  "An edge caused by a 'back' action is a return edge and is rendered distinctly from a forward transition.",
310
310
  "Node ids are sanitized for the diagram language; a screen id is never emitted raw into a diagram identifier position."
311
311
  ],
312
+ "specTransitions": {
313
+ "source": "<screen spec>.transitions[].destination",
314
+ "description": "A spec transition's destination is FREE PROSE, not an identifier. The validator requires only that the key exists (condition + destination), so nothing has ever constrained its shape, and the corpus shows at least six different kinds of value in that one column.",
315
+ "measured": {
316
+ "date": "2026-09-09",
317
+ "scanRoot": "<face>/docs/screens/json/*.spec.json (the generated <face>/docs/html/screens tree is NOT the corpus)",
318
+ "idSpace": "the spec stems under that root, UNION the layout basenames under <face>/docs/screens/layouts (variant-normalized)",
319
+ "faces": 4,
320
+ "transitions": 271,
321
+ "note": "Two of the four faces carry exactly 93 transitions. A count reported without naming its face is therefore ambiguous, and reads as correct either way."
322
+ },
323
+ "kinds": {
324
+ "description": "CLOSED vocabulary. It exists so that a destination which is not a screen id stops being reported as a defect. Declaring only 'screen' would make 119 of 271 measured destinations spec errors, when they are other kinds of transition — that turns a reporting gap into a rejection of correct input, which is the worse direction.",
325
+ "values": [
326
+ {"kind": "screen", "meaning": "resolves to a screen id in this face's id space", "drawn": "a node and an edge"},
327
+ {"kind": "route", "meaning": "a URL path owned by a web router, e.g. '/admin/login'", "drawn": "an edge once the route is mapped to a screen; until then, reported"},
328
+ {"kind": "external", "meaning": "leaves the app entirely (browser, tel:, map app, mailer)", "drawn": "a terminal node, never a screen"},
329
+ {"kind": "none", "meaning": "declares that no screen transition happens (in-page tab switch, SPA state change)", "drawn": "nothing. This is a POSITIVE declaration, not a missing value"},
330
+ {"kind": "back", "meaning": "returns through the navigation stack (pop, dismiss, previous screen)", "drawn": "a return edge, like a flow test's back action"},
331
+ {"kind": "unknown", "meaning": "none of the above matched", "drawn": "nothing, and it is COUNTED AND LISTED — see unresolvedReporting"}
332
+ ]
333
+ },
334
+ "normalization": {
335
+ "description": "Applied in order. Every step is here rather than in a resolver because a rule that lives at one call site is absent from the other two (screen_identity.py, mermaid/flow_graph.py, verify_cmd.py each resolve ids today).",
336
+ "parentheticalsNest": "The strip step COUNTS DEPTH; it is not a match. A flat pattern consumes up to the INNER close of `Name (a=b / onNavigate(Screen.X))` and leaves the OUTER one, producing the candidate `Name )`, which resolves to nothing. Measured 2026-09-09: 5 destinations nest, and 3 of them were unresolvable for this reason alone — the value read like prose the classifier could not handle when it was prose the classifier had damaged.",
337
+ "pipeline": [
338
+ "compare case-insensitively with whitespace, '_' and '-' removed",
339
+ "strip a trailing parenthetical, full-width or half-width, then split on ' or ', '/', and '、' and try each part",
340
+ "apply the face's declared aliases (below)"
341
+ ],
342
+ "aliases": {
343
+ "shape": "each declared alias is (position, affix); position is drawn from the closed set below",
344
+ "positions": ["prefix", "suffix"],
345
+ "positionsNote": "THE TWO POSITIONS OBSERVED SO FAR, not all of them. Infix and partial matches are NOT covered, and saying which is which matters: a generalization that names its coverage makes the third example a counterexample that asks for a redesign, while one that says 'all' makes the same example an exception to be pushed in sideways, leaving the mechanism bent.",
346
+ "measured": "prefix 'Web' resolves 50 of one face's 93 destinations; suffix '画面' resolves 10 (9 distinct spellings) of another face's 69. The two sit at different ends, which is why the mechanism carries a position rather than a bare string. A THIRD affix then turned up that does not behave like those two: suffix 'View' resolves 8 on one face and 2 on ANOTHER, so an affix is not necessarily one face's convention. Per-face declaration still expresses it — each face declares it separately — but the earlier reading that 'an affix helps exactly one face' was drawn from a sample of two. Whether to declare it is the FACE's call, not the library's — the mechanism already accepts it — so the numbers a face needs are recorded here rather than argued: declaring suffix 'View' resolves 10 destinations (8 on one face, 2 on another) and leaves 5 occurrences / 4 spellings that contain 'View' still unresolved, because stripping it yields no id — FullscreenImageViewer, LicensesView, and the Privacy/Terms WebView pair, whose kind is a separate question (embedded page or external browser) that no measurement here answers.",
347
+ "scopingIsARiskChoiceNotAMeasuredOne": {
348
+ "whatWasMeasuredAndFailed": "Applying either affix to ALL FOUR faces unconditionally changes not one count. The other faces' zeros come from having no id that matches after the strip — NOT from the declaration withholding itself. So the corpus cannot tell a per-face declaration from a global rule, and an earlier version of this note claimed it could: it read '0 of the other three faces' as evidence the scoping was working, when that zero was made by the predicate.",
349
+ "whatCanBeMeasured": "How close the global rule is to going wrong. Ids beginning with 'Web' already exist — `web_view`, in two of the four faces. Strip the prefix and you get `view`, which is no face's id TODAY. The distance is ONE id: a face that adds `view` turns the global rule into a silent misresolution that gets drawn in a diagram.",
350
+ "conclusion": "Per-face, because the distance is one id — not because the counts said so. The two sentences above are kept apart on purpose: 'we could not measure a difference' and 'we measured how near the failure is' are different claims, and collapsing them is how a withdrawn reason survives a correct decision."
351
+ }
352
+ }
353
+ },
354
+ "unresolvedReporting": {
355
+ "rule": "The count of 'unknown' destinations is reported PER FACE, never only as a total, together with the scanned total it was drawn from.",
356
+ "rationale": "Measured unresolved rates were 28% / 61% / 50% / 100%. Summed, the face at 100% disappears into the average — and a face that resolves nothing looks exactly like a face with nothing to resolve. A bare zero, or a bare average, cannot tell an instrument that found nothing from one that never reached the corpus."
357
+ }
358
+ },
312
359
  "fileReferences": {
313
360
  "rule": "A file-reference step ({ file, case }) becomes a node for the SCREEN the referenced test covers — resolved through that test's own source.layout — not for the referenced file's name.",
314
361
  "rationale": "A test file NAME is not a screen id. One screen routinely has several test files (login_smoke, booking_complete--bank_pending), so naming the node after the file splits one screen into several nodes; the split-off node also carries no metadata, so it loses the group its test declared and drops into the ungrouped bucket. The basename only looked right because it usually happens to equal the screen id.",
@@ -31,6 +31,24 @@ export interface FileInfo {
31
31
  lastModified: string;
32
32
  /** Freshness bucket. "fresh" <= 30d, "aging" <= 90d, "stale" > 90d. */
33
33
  freshness: "fresh" | "aging" | "stale";
34
+ /**
35
+ * sha256 of the bytes this process actually read.
36
+ *
37
+ * 🚨 THE STALENESS QUESTION IS ABOUT CONTENT, NOT ABOUT mtime. Every
38
+ * jsonui-cli distribution replaces `~/.jsonui-cli` wholesale, so an install
39
+ * moves every mtime whether or not a byte changed — measured 2026-09-09: a
40
+ * release whose `shared/core` was blob-identical to the previous one moved
41
+ * all seven timestamps, and every running server then reported "restart it"
42
+ * while serving exactly the right content. An alarm that fires on every
43
+ * distribution stops being read.
44
+ *
45
+ * The same toolchain already learned this one repo over: `jui build`'s
46
+ * generation manifest records a sha256 and says why — "a record that changes
47
+ * when nothing changed is noise, and noise is what gets ignored" — after an
48
+ * mtime-based version churned 89 entries between two builds with nothing
49
+ * edited. Same conclusion, reached twice, applied once.
50
+ */
51
+ contentHash: string;
34
52
  }
35
53
  export interface DataSourceInfo {
36
54
  attributeDefinitions: FileInfo;
@@ -87,12 +105,18 @@ export declare class SpecLoader {
87
105
  /** ISO 8601 timestamp of when this process loaded its data files. */
88
106
  getLoadedAt(): string;
89
107
  /**
90
- * Paths whose mtime has moved since this process read them.
108
+ * Paths whose CONTENT differs from what this process read.
109
+ *
110
+ * 🔻 mtime is a PRE-FILTER, not the answer. It is cheap and right about the
111
+ * common case (nothing touched the file), so it saves a read — but a moved
112
+ * timestamp on identical bytes is not staleness, and that is exactly what a
113
+ * distribution produces: `~/.jsonui-cli` is replaced wholesale on every
114
+ * install, so every mtime moves every time. Reporting on mtime alone made
115
+ * this fire on 100% of distributions, which is the same as not reporting.
91
116
  *
92
- * Compares against the mtime recorded AT LOAD, not against a wall-clock
93
- * timestamp: "was this file edited after I read it" is a question about
94
- * the file, and comparing a file mtime to Date.now() is a race whenever
95
- * the two land in the same millisecond.
117
+ * ⚠️ A file that disappeared is still reported: the server is serving
118
+ * content whose source is gone, which a reader needs to know even though no
119
+ * hash can be computed for it.
96
120
  */
97
121
  getChangedSinceLoad(): string[];
98
122
  getScreenIdentity(): any;
@@ -1,8 +1,23 @@
1
+ import { createHash } from "crypto";
1
2
  import { existsSync, readFileSync, statSync } from "fs";
2
3
  import { homedir } from "os";
3
4
  import { join, resolve } from "path";
4
5
  import { fileURLToPath } from "url";
5
6
  import { BINDING_RULES, MODIFIER_ORDER, PLATFORM_MAPPING, categorizeCommonAttributes, } from "./data/derived.js";
7
+ /**
8
+ * sha256 of a file's bytes, or "" when it cannot be read.
9
+ *
10
+ * "" never equals a real digest, so an unreadable file compares as CHANGED
11
+ * rather than as unchanged — the safe direction for a staleness check.
12
+ */
13
+ function hashFile(path) {
14
+ try {
15
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
16
+ }
17
+ catch {
18
+ return "";
19
+ }
20
+ }
6
21
  const ALL_PLATFORMS = {
7
22
  swift_generated: true,
8
23
  swift_dynamic: true,
@@ -208,12 +223,18 @@ export class SpecLoader {
208
223
  return this.loadedAt;
209
224
  }
210
225
  /**
211
- * Paths whose mtime has moved since this process read them.
226
+ * Paths whose CONTENT differs from what this process read.
227
+ *
228
+ * 🔻 mtime is a PRE-FILTER, not the answer. It is cheap and right about the
229
+ * common case (nothing touched the file), so it saves a read — but a moved
230
+ * timestamp on identical bytes is not staleness, and that is exactly what a
231
+ * distribution produces: `~/.jsonui-cli` is replaced wholesale on every
232
+ * install, so every mtime moves every time. Reporting on mtime alone made
233
+ * this fire on 100% of distributions, which is the same as not reporting.
212
234
  *
213
- * Compares against the mtime recorded AT LOAD, not against a wall-clock
214
- * timestamp: "was this file edited after I read it" is a question about
215
- * the file, and comparing a file mtime to Date.now() is a race whenever
216
- * the two land in the same millisecond.
235
+ * ⚠️ A file that disappeared is still reported: the server is serving
236
+ * content whose source is gone, which a reader needs to know even though no
237
+ * hash can be computed for it.
217
238
  */
218
239
  getChangedSinceLoad() {
219
240
  const tracked = [
@@ -228,8 +249,15 @@ export class SpecLoader {
228
249
  const changed = [];
229
250
  for (const file of tracked) {
230
251
  try {
231
- const now = statSync(file.path).mtime.toISOString();
232
- if (now !== file.lastModified)
252
+ // 🚫 NO mtime PRE-FILTER. An earlier draft skipped the read when the
253
+ // timestamp had not moved, to save I/O — and an arm caught it missing
254
+ // an edit that landed in the same millisecond as the load. The saving
255
+ // was seven small reads on a diagnostic call nobody runs in a loop;
256
+ // the cost was a checker that answers "unchanged" for a file it never
257
+ // looked at. That is the same shape as the mtime comparison this
258
+ // replaced: a cheap proxy standing in for the question.
259
+ statSync(file.path); // still there? (throws into the catch if not)
260
+ if (hashFile(file.path) !== file.contentHash)
233
261
  changed.push(file.path);
234
262
  }
235
263
  catch {
@@ -482,6 +510,7 @@ export class SpecLoader {
482
510
  path: candidate.path,
483
511
  lastModified: mtime.toISOString(),
484
512
  freshness,
513
+ contentHash: hashFile(candidate.path),
485
514
  };
486
515
  }
487
516
  }
@@ -1,12 +1,20 @@
1
1
  export function register(server, loader) {
2
2
  server.tool("get_data_source", "Report where the MCP server loaded EVERY canonical data file from — attribute_definitions.json (raw schema), component_metadata.json (presentation metadata), screen_identity.json and binding_semantics.json. Shows layer (env / cwd / home / bundled), the absolute path actually read, file mtime and freshness per file, plus when this server process loaded them. Use it when the CLI and the MCP seem to disagree about the canon: it distinguishes a stale file from a server that simply has not been restarted since the file changed.", {}, async () => {
3
3
  const info = loader.getDataSource();
4
+ // 🚨 THE SAME SEVEN THE STALENESS CHECK WATCHES. This list held five
5
+ // while `getChangedSinceLoad` tracked seven, so `staleInMemory` could
6
+ // name a path that appeared in no per-file entry — and a reader who
7
+ // "checked every file reported" had two outside their view. Reported by
8
+ // a consumer lane 2026-09-09. Aligning the two beats documenting the
9
+ // difference: a documented difference asks the reader to count.
4
10
  const files = [
5
11
  info.attributeDefinitions,
6
12
  info.componentMetadata,
7
13
  info.screenIdentity,
8
14
  info.bindingSemantics,
9
15
  info.attributeSemantics,
16
+ info.platformSemantics,
17
+ info.coverage,
10
18
  ].filter((f) => f != null);
11
19
  const stale = files.some((f) => f.freshness === "stale");
12
20
  // The data is read once at construction and cached in memory, so a
@@ -24,12 +32,20 @@ export function register(server, loader) {
24
32
  screenIdentity: info.screenIdentity,
25
33
  bindingSemantics: info.bindingSemantics,
26
34
  attributeSemantics: info.attributeSemantics,
35
+ platformSemantics: info.platformSemantics,
36
+ coverage: info.coverage,
27
37
  componentCount: info.componentCount,
28
38
  commonAttributeCount: info.commonAttributeCount,
29
39
  loadedAt,
30
40
  staleInMemory: changedSinceLoad.length > 0 ? changedSinceLoad : undefined,
41
+ // `freshness` is a property of the FILE (how old it is);
42
+ // `staleInMemory` is a property of THIS SERVER (whether what
43
+ // it holds still matches disk). They are different
44
+ // quantities, and a reader who checks only the first gets
45
+ // "fresh" on every file while the server serves something
46
+ // else. The hint names which one fired.
31
47
  hint: changedSinceLoad.length
32
- ? "These files changed on disk AFTER this server loaded them. The server is serving the older content from memory — restart it."
48
+ ? "The CONTENT of these files differs from what this server loaded (compared by sha256, not by timestamp — a distribution moves every mtime without changing bytes). The server is serving the older content from memory — restart it."
33
49
  : stale
34
50
  ? "At least one data file is > 90 days old. Re-fetch via `npm rebuild jui-tools-mcp-server` or point JSONUI_CLI_PATH at a fresher checkout."
35
51
  : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jui-tools-mcp-server",
3
- "version": "2.10.3",
3
+ "version": "2.11.0",
4
4
  "description": "MCP server for JsonUI project management - wraps jui and jsonui-doc CLIs plus component spec lookup",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "crypto";
1
2
  import { existsSync, readFileSync, statSync } from "fs";
2
3
  import { homedir } from "os";
3
4
  import { join, resolve } from "path";
@@ -37,6 +38,20 @@ export interface ComponentSpec {
37
38
 
38
39
  export type FallbackLayer = "env" | "cwd" | "home" | "bundled";
39
40
 
41
+ /**
42
+ * sha256 of a file's bytes, or "" when it cannot be read.
43
+ *
44
+ * "" never equals a real digest, so an unreadable file compares as CHANGED
45
+ * rather than as unchanged — the safe direction for a staleness check.
46
+ */
47
+ function hashFile(path: string): string {
48
+ try {
49
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
50
+ } catch {
51
+ return "";
52
+ }
53
+ }
54
+
40
55
  export interface FileInfo {
41
56
  /** Which fallback layer provided the file. */
42
57
  layer: FallbackLayer;
@@ -46,6 +61,24 @@ export interface FileInfo {
46
61
  lastModified: string;
47
62
  /** Freshness bucket. "fresh" <= 30d, "aging" <= 90d, "stale" > 90d. */
48
63
  freshness: "fresh" | "aging" | "stale";
64
+ /**
65
+ * sha256 of the bytes this process actually read.
66
+ *
67
+ * 🚨 THE STALENESS QUESTION IS ABOUT CONTENT, NOT ABOUT mtime. Every
68
+ * jsonui-cli distribution replaces `~/.jsonui-cli` wholesale, so an install
69
+ * moves every mtime whether or not a byte changed — measured 2026-09-09: a
70
+ * release whose `shared/core` was blob-identical to the previous one moved
71
+ * all seven timestamps, and every running server then reported "restart it"
72
+ * while serving exactly the right content. An alarm that fires on every
73
+ * distribution stops being read.
74
+ *
75
+ * The same toolchain already learned this one repo over: `jui build`'s
76
+ * generation manifest records a sha256 and says why — "a record that changes
77
+ * when nothing changed is noise, and noise is what gets ignored" — after an
78
+ * mtime-based version churned 89 entries between two builds with nothing
79
+ * edited. Same conclusion, reached twice, applied once.
80
+ */
81
+ contentHash: string;
49
82
  }
50
83
 
51
84
  export interface DataSourceInfo {
@@ -313,12 +346,18 @@ export class SpecLoader {
313
346
  }
314
347
 
315
348
  /**
316
- * Paths whose mtime has moved since this process read them.
349
+ * Paths whose CONTENT differs from what this process read.
350
+ *
351
+ * 🔻 mtime is a PRE-FILTER, not the answer. It is cheap and right about the
352
+ * common case (nothing touched the file), so it saves a read — but a moved
353
+ * timestamp on identical bytes is not staleness, and that is exactly what a
354
+ * distribution produces: `~/.jsonui-cli` is replaced wholesale on every
355
+ * install, so every mtime moves every time. Reporting on mtime alone made
356
+ * this fire on 100% of distributions, which is the same as not reporting.
317
357
  *
318
- * Compares against the mtime recorded AT LOAD, not against a wall-clock
319
- * timestamp: "was this file edited after I read it" is a question about
320
- * the file, and comparing a file mtime to Date.now() is a race whenever
321
- * the two land in the same millisecond.
358
+ * ⚠️ A file that disappeared is still reported: the server is serving
359
+ * content whose source is gone, which a reader needs to know even though no
360
+ * hash can be computed for it.
322
361
  */
323
362
  getChangedSinceLoad(): string[] {
324
363
  const tracked = [
@@ -334,8 +373,15 @@ export class SpecLoader {
334
373
  const changed: string[] = [];
335
374
  for (const file of tracked) {
336
375
  try {
337
- const now = statSync(file.path).mtime.toISOString();
338
- if (now !== file.lastModified) changed.push(file.path);
376
+ // 🚫 NO mtime PRE-FILTER. An earlier draft skipped the read when the
377
+ // timestamp had not moved, to save I/O — and an arm caught it missing
378
+ // an edit that landed in the same millisecond as the load. The saving
379
+ // was seven small reads on a diagnostic call nobody runs in a loop;
380
+ // the cost was a checker that answers "unchanged" for a file it never
381
+ // looked at. That is the same shape as the mtime comparison this
382
+ // replaced: a cheap proxy standing in for the question.
383
+ statSync(file.path); // still there? (throws into the catch if not)
384
+ if (hashFile(file.path) !== file.contentHash) changed.push(file.path);
339
385
  } catch {
340
386
  // Disappeared since load — also a reason to restart.
341
387
  changed.push(file.path);
@@ -649,6 +695,7 @@ export class SpecLoader {
649
695
  path: candidate.path,
650
696
  lastModified: mtime.toISOString(),
651
697
  freshness,
698
+ contentHash: hashFile(candidate.path),
652
699
  };
653
700
  }
654
701
  }
@@ -8,12 +8,20 @@ export function register(server: McpServer, loader: SpecLoader) {
8
8
  {},
9
9
  async () => {
10
10
  const info = loader.getDataSource();
11
+ // 🚨 THE SAME SEVEN THE STALENESS CHECK WATCHES. This list held five
12
+ // while `getChangedSinceLoad` tracked seven, so `staleInMemory` could
13
+ // name a path that appeared in no per-file entry — and a reader who
14
+ // "checked every file reported" had two outside their view. Reported by
15
+ // a consumer lane 2026-09-09. Aligning the two beats documenting the
16
+ // difference: a documented difference asks the reader to count.
11
17
  const files = [
12
18
  info.attributeDefinitions,
13
19
  info.componentMetadata,
14
20
  info.screenIdentity,
15
21
  info.bindingSemantics,
16
22
  info.attributeSemantics,
23
+ info.platformSemantics,
24
+ info.coverage,
17
25
  ].filter((f): f is NonNullable<typeof f> => f != null);
18
26
  const stale = files.some((f) => f.freshness === "stale");
19
27
  // The data is read once at construction and cached in memory, so a
@@ -32,12 +40,20 @@ export function register(server: McpServer, loader: SpecLoader) {
32
40
  screenIdentity: info.screenIdentity,
33
41
  bindingSemantics: info.bindingSemantics,
34
42
  attributeSemantics: info.attributeSemantics,
43
+ platformSemantics: info.platformSemantics,
44
+ coverage: info.coverage,
35
45
  componentCount: info.componentCount,
36
46
  commonAttributeCount: info.commonAttributeCount,
37
47
  loadedAt,
38
48
  staleInMemory: changedSinceLoad.length > 0 ? changedSinceLoad : undefined,
49
+ // `freshness` is a property of the FILE (how old it is);
50
+ // `staleInMemory` is a property of THIS SERVER (whether what
51
+ // it holds still matches disk). They are different
52
+ // quantities, and a reader who checks only the first gets
53
+ // "fresh" on every file while the server serves something
54
+ // else. The hint names which one fired.
39
55
  hint: changedSinceLoad.length
40
- ? "These files changed on disk AFTER this server loaded them. The server is serving the older content from memory — restart it."
56
+ ? "The CONTENT of these files differs from what this server loaded (compared by sha256, not by timestamp — a distribution moves every mtime without changing bytes). The server is serving the older content from memory — restart it."
41
57
  : stale
42
58
  ? "At least one data file is > 90 days old. Re-fetch via `npm rebuild jui-tools-mcp-server` or point JSONUI_CLI_PATH at a fresher checkout."
43
59
  : undefined,
@@ -4,7 +4,7 @@
4
4
  * Layer order under test:
5
5
  * 1. $JSONUI_CLI_PATH 2. ./.jsonui-cli/ 3. ~/.jsonui-cli/ 4. bundled data/
6
6
  */
7
- import { utimesSync } from "fs";
7
+ import { utimesSync, writeFileSync } from "fs";
8
8
  import { join, resolve, sep } from "path";
9
9
  import { pathToFileURL } from "url";
10
10
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -527,17 +527,35 @@ describe("SpecLoader coverage ledger", () => {
527
527
  // The ledger is served from the same in-memory cache as everything else,
528
528
  // so an on-disk refresh MUST surface in getChangedSinceLoad — a tracked
529
529
  // list that omits a file is the "silently stale" trap in a second costume.
530
+ //
531
+ // 🔻 THE PROVOCATION CHANGED 2026-09-10, THE INTENT DID NOT. This used to
532
+ // move the mtimes with utimesSync and expect both paths back, which encoded
533
+ // the old mtime-based comparison. That comparison fired on every jsonui-cli
534
+ // distribution (each install replaces ~/.jsonui-cli wholesale, so every
535
+ // timestamp moves whether or not a byte changed) and was replaced by a
536
+ // sha256 comparison. The claim this arm makes — "the ledger is in the
537
+ // tracked set" — is unaffected; only the way it provokes a change is.
530
538
  it("counts the ledger among the files tracked by getChangedSinceLoad", () => {
531
539
  expect(loader.getChangedSinceLoad()).toEqual([]);
532
540
 
533
541
  const ledgerPath = loader.getDataSource().coverage!.path;
534
542
  const attrPath = loader.getDataSource().attributeDefinitions.path;
535
- const later = new Date(Date.now() + 5000);
536
- utimesSync(ledgerPath, later, later);
537
- utimesSync(attrPath, later, later);
543
+ writeFileSync(ledgerPath, JSON.stringify({ schemaVersion: 1, entries: [] }));
544
+ writeFileSync(attrPath, JSON.stringify({ common: {} }));
538
545
 
539
546
  const changed = loader.getChangedSinceLoad();
540
547
  expect(changed).toContain(ledgerPath);
541
548
  expect(changed).toContain(attrPath);
542
549
  });
550
+
551
+ // 逆向きの腕。Without it, an implementation that reported every tracked file
552
+ // unconditionally would pass the one above — and that is precisely the
553
+ // implementation this release removed.
554
+ it("does not count a tracked file whose bytes did not move", () => {
555
+ const ledgerPath = loader.getDataSource().coverage!.path;
556
+ const later = new Date(Date.now() + 5000);
557
+ utimesSync(ledgerPath, later, later);
558
+
559
+ expect(loader.getChangedSinceLoad()).toEqual([]);
560
+ });
543
561
  });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `staleInMemory` answers "is what I hold still what is on disk" — and that is
3
+ * a question about CONTENT.
4
+ *
5
+ * 🚨 WHY THIS FILE EXISTS. It used to compare mtimes. Every jsonui-cli
6
+ * distribution replaces `~/.jsonui-cli` wholesale, so an install moves every
7
+ * timestamp whether or not a byte changed. Measured 2026-09-09: a release
8
+ * whose `shared/core` blobs were IDENTICAL to the previous release's moved all
9
+ * seven, and three running servers then reported "restart it" while serving
10
+ * exactly the right content. An alarm with a 100% fire rate cannot be a gate.
11
+ *
12
+ * The same toolchain had already reached this conclusion one repo over — the
13
+ * `jui build` generation manifest records a sha256 and its docstring says why:
14
+ * "a record that changes when nothing changed is noise, and noise is what gets
15
+ * ignored". Two implementations of one rule; only one had read it.
16
+ *
17
+ * ⚠️ `freshness` and `staleInMemory` are DIFFERENT QUANTITIES: the first is
18
+ * about the file (how old), the second about this process (what it holds). A
19
+ * reader who checks only the first sees "fresh" on every file while the server
20
+ * serves something else. One arm below pins that they can disagree.
21
+ */
22
+ import { rmSync, statSync, utimesSync, writeFileSync } from "fs";
23
+ import { join } from "path";
24
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
25
+
26
+ import { SpecLoader } from "../src/spec_loader.js";
27
+ import { register as registerDataSource } from "../src/tools/spec/get_data_source.js";
28
+ import {
29
+ cleanupTempDirs,
30
+ createToolHarness,
31
+ makeCliDataset,
32
+ makeTempDir,
33
+ FIXTURE_ATTRIBUTE_DEFINITIONS,
34
+ FIXTURE_COVERAGE,
35
+ } from "./helpers.js";
36
+
37
+ const originalCwd = process.cwd();
38
+ let cliRoot: string;
39
+ let loader: SpecLoader;
40
+
41
+ /** The file every arm here pokes at. */
42
+ function attrPath(): string {
43
+ return join(cliRoot, "shared", "core", "attribute_definitions.json");
44
+ }
45
+
46
+ /** Rewrite a file with the SAME bytes, then move its mtime forward. */
47
+ function touchWithoutChanging(path: string): void {
48
+ const bytes = require("fs").readFileSync(path);
49
+ writeFileSync(path, bytes);
50
+ const future = new Date(Date.now() + 60_000);
51
+ utimesSync(path, future, future);
52
+ }
53
+
54
+ beforeEach(() => {
55
+ cliRoot = makeTempDir("cli");
56
+ makeCliDataset(cliRoot, { coverage: FIXTURE_COVERAGE });
57
+ process.env.JSONUI_CLI_PATH = cliRoot;
58
+ process.chdir(makeTempDir("cwd"));
59
+ process.env.HOME = makeTempDir("home");
60
+ process.env.USERPROFILE = process.env.HOME;
61
+ loader = new SpecLoader(makeTempDir("mcp"));
62
+ });
63
+
64
+ afterEach(() => {
65
+ delete process.env.JSONUI_CLI_PATH;
66
+ process.chdir(originalCwd);
67
+ cleanupTempDirs();
68
+ });
69
+
70
+ describe("a moved timestamp on identical bytes is not staleness", () => {
71
+ it("reports nothing when nothing was touched", () => {
72
+ expect(loader.getChangedSinceLoad()).toEqual([]);
73
+ });
74
+
75
+ it("reports nothing when the file is rewritten with the same bytes", () => {
76
+ const before = statSync(attrPath()).mtime.toISOString();
77
+ touchWithoutChanging(attrPath());
78
+ // 陽性対照 ON THE FIXTURE: without this, an arm that passes because the
79
+ // rewrite silently failed looks identical to one that passes correctly.
80
+ expect(statSync(attrPath()).mtime.toISOString()).not.toBe(before);
81
+
82
+ expect(loader.getChangedSinceLoad()).toEqual([]);
83
+ });
84
+
85
+ it("reports the file when the bytes actually change", () => {
86
+ writeFileSync(
87
+ attrPath(),
88
+ JSON.stringify({ ...FIXTURE_ATTRIBUTE_DEFINITIONS, Extra: {} }, null, 2)
89
+ );
90
+ expect(loader.getChangedSinceLoad()).toContain(attrPath());
91
+ });
92
+
93
+ it("reports the file when it disappears", () => {
94
+ // No hash can be computed, and the server is serving content whose source
95
+ // is gone — the reader needs that, so absence counts as changed.
96
+ rmSync(attrPath());
97
+ expect(loader.getChangedSinceLoad()).toContain(attrPath());
98
+ });
99
+
100
+ it("does not report a sibling that was left alone", () => {
101
+ // 陰性対照: an implementation that reported everything passes the two
102
+ // positive arms above.
103
+ writeFileSync(
104
+ attrPath(),
105
+ JSON.stringify({ ...FIXTURE_ATTRIBUTE_DEFINITIONS, Extra: {} }, null, 2)
106
+ );
107
+ const changed = loader.getChangedSinceLoad();
108
+ expect(changed).toContain(attrPath());
109
+ expect(changed.some((p) => p.endsWith("component_metadata.json"))).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("freshness and staleInMemory are different quantities", () => {
114
+ it("a file can be fresh on disk while the server holds something else", async () => {
115
+ writeFileSync(
116
+ attrPath(),
117
+ JSON.stringify({ ...FIXTURE_ATTRIBUTE_DEFINITIONS, Extra: {} }, null, 2)
118
+ );
119
+ const harness = createToolHarness();
120
+ registerDataSource(harness.server, loader);
121
+ const out = JSON.parse(await harness.call("get_data_source"));
122
+
123
+ // The file is minutes old, so freshness says "fresh" …
124
+ expect(out.attributeDefinitions.freshness).toBe("fresh");
125
+ // … and the server is still serving what it read before the edit.
126
+ expect(out.staleInMemory).toContain(attrPath());
127
+ });
128
+
129
+ it("the hint says the comparison is by content, not by timestamp", async () => {
130
+ writeFileSync(
131
+ attrPath(),
132
+ JSON.stringify({ ...FIXTURE_ATTRIBUTE_DEFINITIONS, Extra: {} }, null, 2)
133
+ );
134
+ const harness = createToolHarness();
135
+ registerDataSource(harness.server, loader);
136
+ const out = JSON.parse(await harness.call("get_data_source"));
137
+ expect(out.hint).toContain("sha256");
138
+ });
139
+
140
+ it("says nothing when the server is current", async () => {
141
+ // 陰性対照 for both arms above.
142
+ const harness = createToolHarness();
143
+ registerDataSource(harness.server, loader);
144
+ const out = JSON.parse(await harness.call("get_data_source"));
145
+ expect(out.staleInMemory).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe("the per-file report covers what the check tracks", () => {
150
+ it("names every file that staleInMemory can name", async () => {
151
+ // The two lists were 5 and 7. A reader who checked "every file reported"
152
+ // had two outside their view — reported by a consumer lane 2026-09-09.
153
+ const harness = createToolHarness();
154
+ registerDataSource(harness.server, loader);
155
+ const out = JSON.parse(await harness.call("get_data_source"));
156
+
157
+ const reported = new Set(
158
+ ["attributeDefinitions", "componentMetadata", "screenIdentity",
159
+ "bindingSemantics", "attributeSemantics", "platformSemantics", "coverage"]
160
+ .map((k) => out[k]?.path)
161
+ .filter(Boolean)
162
+ );
163
+ // Every path the checker watches must appear in the per-file section.
164
+ // Touch them all so the checker returns its full set.
165
+ for (const p of reported) {
166
+ writeFileSync(p as string, require("fs").readFileSync(p as string));
167
+ }
168
+ for (const p of loader.getChangedSinceLoad()) {
169
+ expect(reported.has(p)).toBe(true);
170
+ }
171
+ });
172
+
173
+ it("reports the coverage ledger, which the five-file version omitted", async () => {
174
+ const harness = createToolHarness();
175
+ registerDataSource(harness.server, loader);
176
+ const out = JSON.parse(await harness.call("get_data_source"));
177
+ expect(out.coverage?.path).toContain("coverage.json");
178
+ });
179
+ });