@tpsdev-ai/flair 0.6.0 → 0.6.1

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Round-trip test harness for bridges.
3
+ *
4
+ * A bridge passes the round-trip test iff:
5
+ * 1. Parse the fixture (or descriptor's first import.source.path) → pass1 BridgeMemory[]
6
+ * 2. Apply export.targets[0]: when-filter → map → write to tmp in the
7
+ * target's format
8
+ * 3. Re-import the tmp file using import.sources[0] → pass2 BridgeMemory[]
9
+ * 4. pass1 and pass2 must agree on the round-trip-stable fields from the
10
+ * spec (§8): content, subject, tags, durability.
11
+ *
12
+ * This exercises the full mapper + predicate + writer + parser chain —
13
+ * if a bridge author's descriptor has mapping bugs, the round-trip
14
+ * surfaces them before memories ever reach Flair.
15
+ *
16
+ * Implementation note: does NOT talk to Flair. Fixture-to-fixture only.
17
+ * Lives here so slice-3c code plugins can reuse the same harness.
18
+ */
19
+ import { promises as fsp } from "node:fs";
20
+ import { join, isAbsolute } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+ import { randomUUID } from "node:crypto";
23
+ import { BridgeRuntimeError } from "../types.js";
24
+ import { parseRecords } from "./formats.js";
25
+ import { applyMap } from "./mapper.js";
26
+ import { evaluatePredicate } from "./predicate.js";
27
+ import { writeRecords } from "./writers.js";
28
+ /** Fields the spec (§8) requires to survive round-trip. */
29
+ export const ROUND_TRIP_STABLE_FIELDS = ["content", "subject", "tags", "durability"];
30
+ export async function runRoundTrip(opts) {
31
+ const { descriptor, cwd } = opts;
32
+ if (!descriptor.import || descriptor.import.sources.length === 0) {
33
+ throw new BridgeRuntimeError({
34
+ bridge: descriptor.name,
35
+ op: "test",
36
+ field: "import",
37
+ expected: "descriptor with at least one import source",
38
+ got: "missing or empty",
39
+ hint: "round-trip needs an import block to start from. Add `import.sources:` to the descriptor.",
40
+ });
41
+ }
42
+ if (!descriptor.export || descriptor.export.targets.length === 0) {
43
+ throw new BridgeRuntimeError({
44
+ bridge: descriptor.name,
45
+ op: "test",
46
+ field: "export",
47
+ expected: "descriptor with at least one export target",
48
+ got: "missing or empty",
49
+ hint: "round-trip needs an export block to round-trip through. Add `export.targets:` to the descriptor.",
50
+ });
51
+ }
52
+ const importSource = descriptor.import.sources[0];
53
+ const exportTarget = descriptor.export.targets[0];
54
+ const resolvedFixture = resolvePath(cwd, opts.fixturePath ?? importSource.path);
55
+ const tmpDir = await fsp.mkdtemp(join(tmpdir(), `flair-bridge-test-${descriptor.name}-`));
56
+ const tmpPath = join(tmpDir, `roundtrip.${suffixForFormat(exportTarget.format)}`);
57
+ // Pass 1: fixture → BridgeMemory[]
58
+ const pass1 = [];
59
+ for await (const { record } of parseRecords(descriptor.name, resolvedFixture, importSource.format)) {
60
+ const mapped = applyMap(importSource.map, record);
61
+ pass1.push(mapped);
62
+ }
63
+ // Filter pass1 by the export target's when: (records filtered out wouldn't make it to the target).
64
+ const expected = [];
65
+ for (const m of pass1) {
66
+ if (!exportTarget.when || exportTarget.when.trim() === "") {
67
+ expected.push(m);
68
+ continue;
69
+ }
70
+ const result = evaluatePredicate(exportTarget.when, m);
71
+ if (result === "match" || result === "unparsable")
72
+ expected.push(m);
73
+ }
74
+ // Export phase: apply export.map, write to tmp
75
+ const shaped = [];
76
+ for (const m of expected) {
77
+ const out = applyMap(exportTarget.map, m);
78
+ if (Object.keys(out).length === 0)
79
+ continue;
80
+ shaped.push(out);
81
+ }
82
+ await writeRecords(descriptor.name, tmpPath, exportTarget.format, shaped);
83
+ // Pass 2: re-import the tmp using import.sources[0]'s map + format
84
+ // (round-trip requires the bridge's own import map applied to what export wrote).
85
+ const pass2 = [];
86
+ for await (const { record } of parseRecords(descriptor.name, tmpPath, importSource.format)) {
87
+ const mapped = applyMap(importSource.map, record);
88
+ pass2.push(mapped);
89
+ }
90
+ // Match pass1[expected] with pass2 by key (foreignId if present, else content)
91
+ // and diff the stable fields.
92
+ const keyOf = (m) => m.foreignId ?? (m.content ? `content:${m.content}` : "");
93
+ const pass2ByKey = new Map();
94
+ for (const m of pass2)
95
+ pass2ByKey.set(keyOf(m), m);
96
+ const mismatches = [];
97
+ const missingInPass2 = [];
98
+ for (let i = 0; i < expected.length; i++) {
99
+ const a = expected[i];
100
+ const key = keyOf(a);
101
+ const b = pass2ByKey.get(key);
102
+ if (!b) {
103
+ missingInPass2.push({ ordinal: i + 1, key });
104
+ continue;
105
+ }
106
+ for (const field of ROUND_TRIP_STABLE_FIELDS) {
107
+ if (!deepEqualField(a[field], b[field])) {
108
+ mismatches.push({ ordinal: i + 1, key, field, expected: a[field], got: b[field] });
109
+ }
110
+ }
111
+ // Matched this pass2 entry; remove so leftover tracking works
112
+ pass2ByKey.delete(key);
113
+ }
114
+ const unexpectedInPass2 = Array.from(pass2ByKey.keys()).map((key) => ({ key }));
115
+ const passed = mismatches.length === 0 && missingInPass2.length === 0 && unexpectedInPass2.length === 0;
116
+ return {
117
+ passed,
118
+ expectedCount: expected.length,
119
+ actualCount: pass2.length,
120
+ mismatches,
121
+ missingInPass2,
122
+ unexpectedInPass2,
123
+ tmpExportPath: tmpPath,
124
+ };
125
+ }
126
+ function deepEqualField(a, b) {
127
+ // tags is the only array-valued stable field today; deep-equal shallowly
128
+ // (order matters per our mapping; if a bridge re-orders tags, that's a
129
+ // round-trip regression worth catching).
130
+ if (Array.isArray(a) && Array.isArray(b)) {
131
+ if (a.length !== b.length)
132
+ return false;
133
+ for (let i = 0; i < a.length; i++)
134
+ if (a[i] !== b[i])
135
+ return false;
136
+ return true;
137
+ }
138
+ // string / number / boolean / undefined: strict equality
139
+ return a === b;
140
+ }
141
+ function suffixForFormat(format) {
142
+ switch (format) {
143
+ case "jsonl": return "jsonl";
144
+ case "json": return "json";
145
+ case "yaml": return "yaml";
146
+ case "markdown-frontmatter": return "md";
147
+ default: return "out";
148
+ }
149
+ }
150
+ function resolvePath(cwd, p) {
151
+ return isAbsolute(p) ? p : join(cwd, p);
152
+ }
153
+ // Keep randomUUID imported; future slice may use it for named tmp files.
154
+ void randomUUID;
package/dist/cli.js CHANGED
@@ -324,6 +324,51 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
324
324
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
325
325
  }
326
326
  }
327
+ // ─── Upgrade presence probes ──────────────────────────────────────────────────
328
+ //
329
+ // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
330
+ // version. That assumed the default npm global prefix and failed (silently,
331
+ // reporting "not installed") for mise / fnm / nvm / volta users whose prefix
332
+ // lives elsewhere — including for the running flair binary itself, which is
333
+ // clearly installed somewhere. These probes locate the package regardless of
334
+ // install path.
335
+ export function probeBinVersion(execFileSync, bin) {
336
+ // Run the binary's --version via argv (no shell). PATH resolution still
337
+ // happens (so we find the binary wherever npm/mise/fnm installed it),
338
+ // but there's no shell-string to inject into. CodeQL-safe and simpler.
339
+ try {
340
+ const out = execFileSync(bin, ["--version"], {
341
+ encoding: "utf-8",
342
+ timeout: 5000,
343
+ stdio: ["ignore", "pipe", "ignore"],
344
+ }).trim();
345
+ if (!out)
346
+ return null;
347
+ // Accept either "0.6.0" on its own or a line containing a semver.
348
+ const m = out.match(/\b(\d+\.\d+\.\d+(?:[\d.a-z.-]*)?)\b/);
349
+ return m ? m[1] : null;
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ }
355
+ export function probeLibVersion(pkgName) {
356
+ // Resolve the package's package.json from the running flair's module graph.
357
+ // If the lib is installed anywhere Node can see (including bundled as a
358
+ // dep of flair itself, sibling global install, or linked workspace), this
359
+ // finds it. If it's truly missing, require.resolve throws → null.
360
+ try {
361
+ const { createRequire } = require("node:module");
362
+ const req = createRequire(import.meta.url);
363
+ const pkgJsonPath = req.resolve(`${pkgName}/package.json`);
364
+ const { readFileSync } = require("node:fs");
365
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
366
+ return typeof pkg.version === "string" ? pkg.version : null;
367
+ }
368
+ catch {
369
+ return null;
370
+ }
371
+ }
327
372
  export function templateSoul(choice) {
328
373
  const templates = {
329
374
  "1": [
@@ -2363,52 +2408,80 @@ program
2363
2408
  .option("--check", "Only check for updates, don't install")
2364
2409
  .option("--restart", "Restart Flair after upgrade")
2365
2410
  .action(async (opts) => {
2366
- const { execSync } = await import("node:child_process");
2411
+ const { execSync, execFileSync } = await import("node:child_process");
2367
2412
  const checkOnly = opts.check ?? false;
2368
2413
  console.log("Checking for updates...\n");
2414
+ // Per-package install probes. `npm list -g` assumed the default global
2415
+ // prefix and silently mis-reported "not installed" for anyone using
2416
+ // mise / fnm / nvm / volta / non-default-prefix npm — including the
2417
+ // running flair binary itself, which was obviously installed. Each
2418
+ // entry now has a locator that works regardless of install path:
2419
+ //
2420
+ // - For packages with a bin: shell out to the bin with --version
2421
+ // (same PATH lookup that got them invokable in the first place).
2422
+ // - For library packages: require.resolve the package.json from the
2423
+ // running flair's module graph (works whether it's a sibling
2424
+ // global install or a bundled dep).
2369
2425
  const packages = [
2370
- "@tpsdev-ai/flair",
2371
- "@tpsdev-ai/flair-client",
2372
- "@tpsdev-ai/flair-mcp",
2426
+ {
2427
+ name: "@tpsdev-ai/flair",
2428
+ probe: () => probeBinVersion(execFileSync, "flair"),
2429
+ },
2430
+ {
2431
+ name: "@tpsdev-ai/flair-client",
2432
+ probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
2433
+ },
2434
+ {
2435
+ name: "@tpsdev-ai/flair-mcp",
2436
+ probe: () => probeBinVersion(execFileSync, "flair-mcp"),
2437
+ },
2373
2438
  ];
2374
- const upgrades = [];
2375
- for (const pkg of packages) {
2439
+ const findings = [];
2440
+ for (const { name, probe } of packages) {
2376
2441
  try {
2377
- const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
2442
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
2378
2443
  if (!res.ok)
2379
2444
  continue;
2380
2445
  const data = await res.json();
2381
2446
  const latest = data.version ?? "unknown";
2382
- let installed = "not installed";
2383
- try {
2384
- const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null || true`, { encoding: "utf-8" }).trim();
2385
- const match = out.match(/@(\d+\.\d+[\d.a-z-]*)/);
2386
- installed = match ? match[1] : "not installed";
2387
- }
2388
- catch { /* best effort */ }
2389
- const upToDate = installed === latest;
2390
- const icon = upToDate ? "✅" : "⬆️";
2391
- console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
2392
- if (!upToDate && installed !== "not installed") {
2393
- upgrades.push({ pkg, installed, latest });
2394
- }
2447
+ const installed = probe();
2448
+ const status = installed === null ? "missing" : installed === latest ? "current" : "outdated";
2449
+ findings.push({ name, installed, latest, status });
2450
+ const icon = status === "current" ? "✅" : status === "outdated" ? "⬆️" : "❔";
2451
+ const installedLabel = installed ?? "not detected";
2452
+ const suffix = status === "current" ? " (current)" : status === "missing" ? " (run: npm install -g)" : "";
2453
+ console.log(` ${icon} ${name}: ${installedLabel} ${latest}${suffix}`);
2395
2454
  }
2396
2455
  catch { /* skip unavailable packages */ }
2397
2456
  }
2398
- if (upgrades.length === 0) {
2457
+ const outdated = findings.filter((f) => f.status === "outdated");
2458
+ const missing = findings.filter((f) => f.status === "missing");
2459
+ const upgrades = outdated.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
2460
+ if (outdated.length === 0 && missing.length === 0) {
2399
2461
  console.log("\n✅ Everything is up to date.");
2400
2462
  return;
2401
2463
  }
2464
+ if (missing.length > 0 && outdated.length === 0) {
2465
+ console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
2466
+ console.log(` Install missing: npm install -g ${missing.map((f) => f.name).join(" ")}`);
2467
+ return;
2468
+ }
2402
2469
  if (checkOnly) {
2403
- console.log(`\n${upgrades.length} update${upgrades.length > 1 ? "s" : ""} available. Run: flair upgrade`);
2470
+ console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade`);
2471
+ if (missing.length > 0) {
2472
+ console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
2473
+ }
2404
2474
  return;
2405
2475
  }
2406
- // Perform upgrade
2476
+ // Perform upgrade. `latest` comes from the npm registry's HTTP
2477
+ // response, so CodeQL (correctly) treats it as untrusted input.
2478
+ // Use execFileSync with argv — the spec `<name>@<version>` becomes a
2479
+ // single argument to npm, no shell to inject into.
2407
2480
  console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
2408
2481
  for (const { pkg, latest } of upgrades) {
2409
2482
  try {
2410
2483
  console.log(` Installing ${pkg}@${latest}...`);
2411
- execSync(`npm install -g ${pkg}@${latest}`, { stdio: "pipe" });
2484
+ execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
2412
2485
  console.log(` ✅ ${pkg}@${latest} installed`);
2413
2486
  }
2414
2487
  catch (err) {
@@ -3679,15 +3752,78 @@ bridge
3679
3752
  process.exit(1);
3680
3753
  }
3681
3754
  });
3682
- // `test` still stubbed — round-trip harness lands in slice 3b.
3683
3755
  bridge
3684
- .command("test <name> [args...]")
3685
- .description("test a bridge not yet implemented (slice 3b of FLAIR-BRIDGES)")
3686
- .allowUnknownOption()
3687
- .action(() => {
3688
- console.error(`\`flair bridge test\` is not yet implemented landing in slice 3b of FLAIR-BRIDGES.`);
3689
- console.error(`Slice 3a ships export (Shape A YAML); the round-trip test harness pairs with it.`);
3690
- process.exit(2);
3756
+ .command("test <name>")
3757
+ .description("Round-trip a bridge through its fixture: import export re-import → diff. Pass iff the stable fields (content/subject/tags/durability) match.")
3758
+ .option("--fixture <path>", "Override the import source path (defaults to descriptor's import.sources[0].path)")
3759
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
3760
+ .option("--json", "Emit the full RoundTripResult as JSON on stdout")
3761
+ .action(async (name, opts) => {
3762
+ const cwd = opts.cwd ?? process.cwd();
3763
+ const { discover } = await import("./bridges/discover.js");
3764
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3765
+ const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3766
+ const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
3767
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3768
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3769
+ const target = found.find((b) => b.name === name);
3770
+ if (!target) {
3771
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3772
+ process.exit(1);
3773
+ }
3774
+ let descriptor;
3775
+ try {
3776
+ descriptor = await loadDescriptor(target);
3777
+ }
3778
+ catch (err) {
3779
+ printBridgeError(err);
3780
+ process.exit(1);
3781
+ }
3782
+ try {
3783
+ const result = await runRoundTrip({ descriptor, cwd, fixturePath: opts.fixture });
3784
+ if (opts.json) {
3785
+ console.log(JSON.stringify(result, null, 2));
3786
+ process.exit(result.passed ? 0 : 1);
3787
+ }
3788
+ if (result.passed) {
3789
+ console.log(`✅ ${descriptor.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
3790
+ process.exit(0);
3791
+ }
3792
+ console.log(`❌ ${descriptor.name} round-trip failed.`);
3793
+ console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
3794
+ if (result.missingInPass2.length > 0) {
3795
+ console.log(` missing from re-import (${result.missingInPass2.length}):`);
3796
+ for (const m of result.missingInPass2.slice(0, 5))
3797
+ console.log(` - ${m.key}`);
3798
+ if (result.missingInPass2.length > 5)
3799
+ console.log(` ... ${result.missingInPass2.length - 5} more`);
3800
+ }
3801
+ if (result.unexpectedInPass2.length > 0) {
3802
+ console.log(` unexpected extras in re-import (${result.unexpectedInPass2.length}):`);
3803
+ for (const m of result.unexpectedInPass2.slice(0, 5))
3804
+ console.log(` - ${m.key}`);
3805
+ if (result.unexpectedInPass2.length > 5)
3806
+ console.log(` ... ${result.unexpectedInPass2.length - 5} more`);
3807
+ }
3808
+ if (result.mismatches.length > 0) {
3809
+ console.log(` field mismatches (${result.mismatches.length}):`);
3810
+ for (const m of result.mismatches.slice(0, 10)) {
3811
+ console.log(` - record ${m.ordinal} (${m.key}) field ${m.field}: expected ${JSON.stringify(m.expected)}, got ${JSON.stringify(m.got)}`);
3812
+ }
3813
+ if (result.mismatches.length > 10)
3814
+ console.log(` ... ${result.mismatches.length - 10} more`);
3815
+ }
3816
+ console.log(`\n Intermediate export at: ${result.tmpExportPath}`);
3817
+ process.exit(1);
3818
+ }
3819
+ catch (err) {
3820
+ if (err instanceof BridgeRuntimeError) {
3821
+ printBridgeError(err);
3822
+ process.exit(1);
3823
+ }
3824
+ console.error(`Bridge test failed: ${err?.message ?? err}`);
3825
+ process.exit(1);
3826
+ }
3691
3827
  });
3692
3828
  function printBridgeError(err) {
3693
3829
  // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",