tina4-nodejs 3.12.10 → 3.13.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.
- package/CLAUDE.md +17 -17
- package/package.json +14 -5
- package/packages/cli/src/commands/migrate.ts +14 -4
- package/packages/cli/src/commands/migrateRollback.ts +12 -4
- package/packages/cli/src/commands/migrateStatus.ts +10 -4
- package/packages/core/src/__feedback/widget.js +96 -0
- package/packages/core/src/api.ts +65 -3
- package/packages/core/src/auth.ts +15 -8
- package/packages/core/src/devAdmin.ts +228 -10
- package/packages/core/src/errorOverlay.ts +41 -3
- package/packages/core/src/feedback.ts +277 -0
- package/packages/core/src/graphql.ts +99 -1
- package/packages/core/src/index.ts +15 -2
- package/packages/core/src/mcp.test.ts +301 -0
- package/packages/core/src/mcp.ts +302 -7
- package/packages/core/src/plan.ts +56 -15
- package/packages/core/src/request.ts +17 -1
- package/packages/core/src/routeDiscovery.ts +69 -1
- package/packages/core/src/router.ts +75 -16
- package/packages/core/src/server.ts +102 -3
- package/packages/core/src/service.ts +87 -0
- package/packages/core/src/static.ts +9 -2
- package/packages/core/src/test.ts +246 -0
- package/packages/core/src/types.ts +18 -0
- package/packages/orm/src/database.ts +62 -0
|
@@ -403,8 +403,106 @@ export class GraphQL {
|
|
|
403
403
|
private types: Map<string, Record<string, GraphQLField>> = new Map();
|
|
404
404
|
private queries: Map<string, QueryConfig> = new Map();
|
|
405
405
|
private mutations: Map<string, QueryConfig> = new Map();
|
|
406
|
+
/** Object-type field resolvers indexed by `[typeName][fieldName]`. */
|
|
407
|
+
private fieldResolvers: Map<string, Map<string, ResolverFn>> = new Map();
|
|
408
|
+
|
|
409
|
+
// ── Class-level resolver registry — 3.13.1 ──────────────────────────
|
|
410
|
+
//
|
|
411
|
+
// Resolvers registered via `GraphQL.resolve("Type", "field", fn)`
|
|
412
|
+
// accumulate here BEFORE any GraphQL instance exists. When `new GraphQL()`
|
|
413
|
+
// runs, the instance drains the registry into its schema. Cross-framework
|
|
414
|
+
// parity with Python @GraphQL.resolve, PHP GraphQL::resolve, Ruby
|
|
415
|
+
// Tina4::GraphQL.resolve.
|
|
416
|
+
private static classResolvers = new Map<string, Map<string, ResolverFn>>();
|
|
417
|
+
private static defaultInstance: GraphQL | null = null;
|
|
406
418
|
|
|
407
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Decorator-style resolver registration.
|
|
421
|
+
*
|
|
422
|
+
* GraphQL.resolve("Query", "products", async (root, args) =>
|
|
423
|
+
* db.fetchAll("SELECT * FROM products"));
|
|
424
|
+
*
|
|
425
|
+
* GraphQL.resolve("Mutation", "createProduct", async (root, args) => {
|
|
426
|
+
* const p = new Product(args.input);
|
|
427
|
+
* await p.save();
|
|
428
|
+
* return p.toDict();
|
|
429
|
+
* });
|
|
430
|
+
*
|
|
431
|
+
* GraphQL.resolve("Product", "reviews", async (product, args) =>
|
|
432
|
+
* db.fetchAll("SELECT * FROM reviews WHERE product_id = ?", [product.id]));
|
|
433
|
+
*
|
|
434
|
+
* Resolvers registered before any GraphQL instance exists accumulate
|
|
435
|
+
* in the class-level registry. `new GraphQL()` drains them into its
|
|
436
|
+
* schema. Resolvers registered after `setDefault(gql)` wire into the
|
|
437
|
+
* live schema immediately.
|
|
438
|
+
*/
|
|
439
|
+
static resolve(typeName: string, fieldName: string, resolver: ResolverFn): void {
|
|
440
|
+
let typeMap = GraphQL.classResolvers.get(typeName);
|
|
441
|
+
if (!typeMap) {
|
|
442
|
+
typeMap = new Map();
|
|
443
|
+
GraphQL.classResolvers.set(typeName, typeMap);
|
|
444
|
+
}
|
|
445
|
+
typeMap.set(fieldName, resolver);
|
|
446
|
+
|
|
447
|
+
if (GraphQL.defaultInstance) {
|
|
448
|
+
GraphQL.defaultInstance.attachResolver(typeName, fieldName, resolver);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Designate `instance` as the default singleton. Post-startup
|
|
454
|
+
* `GraphQL.resolve()` calls wire into this instance's live schema.
|
|
455
|
+
*/
|
|
456
|
+
static setDefault(instance: GraphQL): void {
|
|
457
|
+
GraphQL.defaultInstance = instance;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Test-only — clear the class-level registry. */
|
|
461
|
+
static _clearClassResolvers(): void {
|
|
462
|
+
GraphQL.classResolvers.clear();
|
|
463
|
+
GraphQL.defaultInstance = null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
constructor() {
|
|
467
|
+
// Drain any resolvers registered via the class-level GraphQL.resolve()
|
|
468
|
+
// BEFORE this instance was constructed.
|
|
469
|
+
for (const [typeName, fields] of GraphQL.classResolvers.entries()) {
|
|
470
|
+
for (const [fieldName, resolver] of fields.entries()) {
|
|
471
|
+
this.attachResolver(typeName, fieldName, resolver);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Wire a single resolver into the live schema. */
|
|
477
|
+
private attachResolver(typeName: string, fieldName: string, resolver: ResolverFn): void {
|
|
478
|
+
if (typeName === "Query") {
|
|
479
|
+
const existing = this.queries.get(fieldName) ?? { args: {}, returnType: "String", resolver };
|
|
480
|
+
existing.resolver = resolver;
|
|
481
|
+
this.queries.set(fieldName, existing);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (typeName === "Mutation") {
|
|
485
|
+
const existing = this.mutations.get(fieldName) ?? { args: {}, returnType: "String", resolver };
|
|
486
|
+
existing.resolver = resolver;
|
|
487
|
+
this.mutations.set(fieldName, existing);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// Object-type field resolver
|
|
491
|
+
let typeMap = this.fieldResolvers.get(typeName);
|
|
492
|
+
if (!typeMap) {
|
|
493
|
+
typeMap = new Map();
|
|
494
|
+
this.fieldResolvers.set(typeName, typeMap);
|
|
495
|
+
}
|
|
496
|
+
typeMap.set(fieldName, resolver);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Get the field resolver registered for an object type, if any.
|
|
501
|
+
* Used by the executor during nested field resolution.
|
|
502
|
+
*/
|
|
503
|
+
getFieldResolver(typeName: string, fieldName: string): ResolverFn | undefined {
|
|
504
|
+
return this.fieldResolvers.get(typeName)?.get(fieldName);
|
|
505
|
+
}
|
|
408
506
|
|
|
409
507
|
/**
|
|
410
508
|
* Return schema metadata for debugging.
|
|
@@ -64,14 +64,25 @@ export {
|
|
|
64
64
|
CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
|
|
65
65
|
} from "./websocket.js";
|
|
66
66
|
export type { WebSocketClient } from "./websocket.js";
|
|
67
|
-
export { ServiceRunner, matchCronField, matchesCron } from "./service.js";
|
|
67
|
+
export { ServiceRunner, Tina4Service, matchCronField, matchesCron } from "./service.js";
|
|
68
68
|
export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from "./service.js";
|
|
69
69
|
export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, _resetBackend } from "./cache.js";
|
|
70
70
|
export type { ResponseCacheConfig } from "./cache.js";
|
|
71
71
|
export { Api } from "./api.js";
|
|
72
72
|
export type { ApiResult } from "./api.js";
|
|
73
73
|
export { Events } from "./events.js";
|
|
74
|
-
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker } from "./devAdmin.js";
|
|
74
|
+
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl } from "./devAdmin.js";
|
|
75
|
+
export {
|
|
76
|
+
feedbackEnabled,
|
|
77
|
+
feedbackWhitelist,
|
|
78
|
+
feedbackIdentifyUser,
|
|
79
|
+
feedbackIsWhitelisted,
|
|
80
|
+
feedbackRateLimitOk,
|
|
81
|
+
injectFeedbackWidget,
|
|
82
|
+
handleFeedbackTurn,
|
|
83
|
+
handleFeedbackWidgetJs,
|
|
84
|
+
registerFeedbackRoutes,
|
|
85
|
+
} from "./feedback.js";
|
|
75
86
|
export { Messenger } from "./messenger.js";
|
|
76
87
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
77
88
|
export { DevMailbox, createMessenger } from "./devMailbox.js";
|
|
@@ -99,6 +110,8 @@ export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
|
99
110
|
export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
|
|
100
111
|
export { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "./testing.js";
|
|
101
112
|
export { TestClient, TestResponse } from "./testClient.js";
|
|
113
|
+
export { Tina4Test, AssertionError as Tina4AssertionError } from "./test.js";
|
|
114
|
+
export type { TestRunResults } from "./test.js";
|
|
102
115
|
export { Container, container } from "./container.js";
|
|
103
116
|
export { Validator } from "./validator.js";
|
|
104
117
|
export type { ValidationError } from "./validator.js";
|
|
@@ -390,6 +390,307 @@ console.log("\nInstance Registry");
|
|
|
390
390
|
assert("instances — cleared", McpServer._instances.length === 0);
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// ── Defensive Write Helpers (Tier 1 parity port from Python) ─
|
|
394
|
+
|
|
395
|
+
console.log("\nDefensive Write Helpers");
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Invoke an MCP tool and unwrap the JSON-RPC + tools/call envelope.
|
|
399
|
+
* The MCP `tools/call` response shape is:
|
|
400
|
+
* { jsonrpc, id, result: { content: [{ type: "text", text: "<json>" }] } }
|
|
401
|
+
* For string returns the inner text is the raw string; for object returns
|
|
402
|
+
* it is JSON-encoded — try to parse it back, fall through to the raw text.
|
|
403
|
+
*/
|
|
404
|
+
function callTool(server: McpServer, name: string, args: Record<string, unknown>): {
|
|
405
|
+
rpc: { jsonrpc?: string; id?: unknown; result?: unknown; error?: { code: number; message: string } };
|
|
406
|
+
result: Record<string, unknown> | string | null;
|
|
407
|
+
} {
|
|
408
|
+
const rpc = JSON.parse(server.handleMessage({
|
|
409
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
410
|
+
params: { name, arguments: args },
|
|
411
|
+
}));
|
|
412
|
+
let result: Record<string, unknown> | string | null = null;
|
|
413
|
+
const content = (rpc.result as { content?: Array<{ text?: string }> } | undefined)?.content;
|
|
414
|
+
if (content && content.length > 0 && typeof content[0].text === "string") {
|
|
415
|
+
const raw = content[0].text;
|
|
416
|
+
try {
|
|
417
|
+
result = JSON.parse(raw);
|
|
418
|
+
} catch {
|
|
419
|
+
result = raw;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { rpc, result };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// refuses prose paths in file_write
|
|
426
|
+
{
|
|
427
|
+
McpServer._instances = [];
|
|
428
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
429
|
+
const oldCwd = process.cwd();
|
|
430
|
+
process.chdir(tmpDir);
|
|
431
|
+
try {
|
|
432
|
+
const server = new McpServer("/test-prose", "Prose Test");
|
|
433
|
+
registerDevTools(server);
|
|
434
|
+
const { result } = callTool(server, "file_write", {
|
|
435
|
+
path: "The plan requires implementing a new feature for users.ts",
|
|
436
|
+
content: "x",
|
|
437
|
+
});
|
|
438
|
+
assert(
|
|
439
|
+
"refuses prose paths in file_write",
|
|
440
|
+
typeof result === "object" && result !== null && typeof (result as Record<string, unknown>).error === "string",
|
|
441
|
+
`Got: ${JSON.stringify(result)}`,
|
|
442
|
+
);
|
|
443
|
+
} finally {
|
|
444
|
+
process.chdir(oldCwd);
|
|
445
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// normalizes bare routes/ to src/routes/
|
|
450
|
+
{
|
|
451
|
+
McpServer._instances = [];
|
|
452
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
453
|
+
const oldCwd = process.cwd();
|
|
454
|
+
process.chdir(tmpDir);
|
|
455
|
+
try {
|
|
456
|
+
const server = new McpServer("/test-normalize", "Normalize Test");
|
|
457
|
+
registerDevTools(server);
|
|
458
|
+
const { result } = callTool(server, "file_write", {
|
|
459
|
+
path: "routes/foo.ts",
|
|
460
|
+
content: "export default async function (req, res) {}\n",
|
|
461
|
+
});
|
|
462
|
+
const landedAt = path.join(tmpDir, "src", "routes", "foo.ts");
|
|
463
|
+
const written = (result as { written?: string } | null)?.written;
|
|
464
|
+
assert(
|
|
465
|
+
"normalizes bare routes/ to src/routes/ — file lands at src/routes/foo.ts",
|
|
466
|
+
fs.existsSync(landedAt) && (written ?? "").replace(/\\/g, "/") === "src/routes/foo.ts",
|
|
467
|
+
`Got: ${JSON.stringify(result)}; exists=${fs.existsSync(landedAt)}`,
|
|
468
|
+
);
|
|
469
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
470
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
471
|
+
assert(
|
|
472
|
+
"normalizes bare routes/ to src/routes/ — agent.log has path_normalized entry",
|
|
473
|
+
logContent.includes("write.path_normalized") && logContent.includes("routes/foo.ts"),
|
|
474
|
+
`Log content: ${logContent.slice(0, 300)}`,
|
|
475
|
+
);
|
|
476
|
+
} finally {
|
|
477
|
+
process.chdir(oldCwd);
|
|
478
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// backs up existing file before overwrite
|
|
483
|
+
{
|
|
484
|
+
McpServer._instances = [];
|
|
485
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
486
|
+
const oldCwd = process.cwd();
|
|
487
|
+
process.chdir(tmpDir);
|
|
488
|
+
try {
|
|
489
|
+
const server = new McpServer("/test-backup", "Backup Test");
|
|
490
|
+
registerDevTools(server);
|
|
491
|
+
const target = path.join(tmpDir, "src", "routes", "foo.ts");
|
|
492
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
493
|
+
fs.writeFileSync(target, "original content\n", "utf-8");
|
|
494
|
+
|
|
495
|
+
const { result } = callTool(server, "file_write", {
|
|
496
|
+
path: "src/routes/foo.ts",
|
|
497
|
+
content: "new content that is reasonably similar in length to the old one\n",
|
|
498
|
+
});
|
|
499
|
+
const backup = (result as { backup?: string } | null)?.backup ?? "";
|
|
500
|
+
const backupDir = path.join(tmpDir, ".tina4", "backups");
|
|
501
|
+
const backups = fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [];
|
|
502
|
+
assert(
|
|
503
|
+
"backs up existing file before overwrite — backup in .tina4/backups/",
|
|
504
|
+
backups.length === 1 && backup.startsWith(".tina4/backups/"),
|
|
505
|
+
`Got: ${JSON.stringify(result)}; backups=${JSON.stringify(backups)}`,
|
|
506
|
+
);
|
|
507
|
+
assert(
|
|
508
|
+
"backs up existing file before overwrite — backup contains original",
|
|
509
|
+
backups.length === 1 &&
|
|
510
|
+
fs.readFileSync(path.join(backupDir, backups[0]), "utf-8") === "original content\n",
|
|
511
|
+
);
|
|
512
|
+
} finally {
|
|
513
|
+
process.chdir(oldCwd);
|
|
514
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// refuses suspicious truncation
|
|
519
|
+
{
|
|
520
|
+
McpServer._instances = [];
|
|
521
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
522
|
+
const oldCwd = process.cwd();
|
|
523
|
+
process.chdir(tmpDir);
|
|
524
|
+
try {
|
|
525
|
+
const server = new McpServer("/test-truncation", "Truncation Test");
|
|
526
|
+
registerDevTools(server);
|
|
527
|
+
const target = path.join(tmpDir, "src", "routes", "big.ts");
|
|
528
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
529
|
+
// 500-byte original
|
|
530
|
+
const original = "x".repeat(500);
|
|
531
|
+
fs.writeFileSync(target, original, "utf-8");
|
|
532
|
+
|
|
533
|
+
// Overwrite with 50 bytes — should be REFUSED (50/500 = 10% < 30%)
|
|
534
|
+
const { result } = callTool(server, "file_write", {
|
|
535
|
+
path: "src/routes/big.ts",
|
|
536
|
+
content: "y".repeat(50),
|
|
537
|
+
});
|
|
538
|
+
const r = result as { error?: string; refused?: boolean } | null;
|
|
539
|
+
assert(
|
|
540
|
+
"refuses suspicious truncation — returns error with refused flag",
|
|
541
|
+
r !== null && r.refused === true && typeof r.error === "string" && r.error.includes("REFUSED"),
|
|
542
|
+
`Got: ${JSON.stringify(result)}`,
|
|
543
|
+
);
|
|
544
|
+
const stillThere = fs.readFileSync(target, "utf-8");
|
|
545
|
+
assert(
|
|
546
|
+
"refuses suspicious truncation — original file intact",
|
|
547
|
+
stillThere === original,
|
|
548
|
+
`Original was modified: length=${stillThere.length}`,
|
|
549
|
+
);
|
|
550
|
+
} finally {
|
|
551
|
+
process.chdir(oldCwd);
|
|
552
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// lets canonical src/ paths pass through (no rewrite)
|
|
557
|
+
{
|
|
558
|
+
McpServer._instances = [];
|
|
559
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
560
|
+
const oldCwd = process.cwd();
|
|
561
|
+
process.chdir(tmpDir);
|
|
562
|
+
try {
|
|
563
|
+
const server = new McpServer("/test-passthrough", "Passthrough Test");
|
|
564
|
+
registerDevTools(server);
|
|
565
|
+
const { result } = callTool(server, "file_write", {
|
|
566
|
+
path: "src/routes/foo.ts",
|
|
567
|
+
content: "export default async function (req, res) {}\n",
|
|
568
|
+
});
|
|
569
|
+
const landedAt = path.join(tmpDir, "src", "routes", "foo.ts");
|
|
570
|
+
const written = (result as { written?: string } | null)?.written;
|
|
571
|
+
assert(
|
|
572
|
+
"lets canonical src/ paths pass through — file lands at src/routes/foo.ts",
|
|
573
|
+
fs.existsSync(landedAt) && (written ?? "").replace(/\\/g, "/") === "src/routes/foo.ts",
|
|
574
|
+
`Got: ${JSON.stringify(result)}`,
|
|
575
|
+
);
|
|
576
|
+
// Path should NOT have been double-prefixed to src/src/routes/foo.ts
|
|
577
|
+
const doublyPrefixed = path.join(tmpDir, "src", "src", "routes", "foo.ts");
|
|
578
|
+
assert(
|
|
579
|
+
"lets canonical src/ paths pass through — no double-prefix",
|
|
580
|
+
!fs.existsSync(doublyPrefixed),
|
|
581
|
+
);
|
|
582
|
+
// agent.log should NOT contain a path_normalized entry for this write
|
|
583
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
584
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
585
|
+
assert(
|
|
586
|
+
"lets canonical src/ paths pass through — no path_normalized log entry",
|
|
587
|
+
!logContent.includes("write.path_normalized"),
|
|
588
|
+
`Log content: ${logContent.slice(0, 300)}`,
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
process.chdir(oldCwd);
|
|
592
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// catches JS syntax errors via node --check
|
|
597
|
+
{
|
|
598
|
+
McpServer._instances = [];
|
|
599
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
600
|
+
const oldCwd = process.cwd();
|
|
601
|
+
process.chdir(tmpDir);
|
|
602
|
+
try {
|
|
603
|
+
const server = new McpServer("/test-verify-js", "Verify JS");
|
|
604
|
+
registerDevTools(server);
|
|
605
|
+
const { result } = callTool(server, "file_write", {
|
|
606
|
+
path: "src/routes/broken.js",
|
|
607
|
+
content: "const x = ;\n",
|
|
608
|
+
});
|
|
609
|
+
const r = result as { written?: string; import_error?: string } | null;
|
|
610
|
+
assert(
|
|
611
|
+
"catches JS syntax errors via node --check — result has import_error",
|
|
612
|
+
r !== null && typeof r.import_error === "string" && r.import_error.length > 0,
|
|
613
|
+
`Got: ${JSON.stringify(result)}`,
|
|
614
|
+
);
|
|
615
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
616
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
617
|
+
assert(
|
|
618
|
+
"catches JS syntax errors via node --check — agent.log has write.import_failed",
|
|
619
|
+
logContent.includes("write.import_failed") && logContent.includes("src/routes/broken.js"),
|
|
620
|
+
`Log content: ${logContent.slice(0, 400)}`,
|
|
621
|
+
);
|
|
622
|
+
} finally {
|
|
623
|
+
process.chdir(oldCwd);
|
|
624
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// skips non-code files (no syntax check attempted)
|
|
629
|
+
{
|
|
630
|
+
McpServer._instances = [];
|
|
631
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
632
|
+
const oldCwd = process.cwd();
|
|
633
|
+
process.chdir(tmpDir);
|
|
634
|
+
try {
|
|
635
|
+
const server = new McpServer("/test-verify-skip-ext", "Verify Skip Ext");
|
|
636
|
+
registerDevTools(server);
|
|
637
|
+
// .twig content that would obviously fail any JS parser
|
|
638
|
+
const { result } = callTool(server, "file_write", {
|
|
639
|
+
path: "src/templates/page.twig",
|
|
640
|
+
content: "{% if not valid js %}<h1>Hi</h1>{% endif %}\n",
|
|
641
|
+
});
|
|
642
|
+
const r = result as { written?: string; import_error?: string } | null;
|
|
643
|
+
assert(
|
|
644
|
+
"skips non-code files — no import_error attached",
|
|
645
|
+
r !== null && r.import_error === undefined && typeof r.written === "string",
|
|
646
|
+
`Got: ${JSON.stringify(result)}`,
|
|
647
|
+
);
|
|
648
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
649
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
650
|
+
assert(
|
|
651
|
+
"skips non-code files — no write.import_failed in agent.log",
|
|
652
|
+
!logContent.includes("write.import_failed"),
|
|
653
|
+
`Log content: ${logContent.slice(0, 400)}`,
|
|
654
|
+
);
|
|
655
|
+
} finally {
|
|
656
|
+
process.chdir(oldCwd);
|
|
657
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// skips files outside src/
|
|
662
|
+
{
|
|
663
|
+
McpServer._instances = [];
|
|
664
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
665
|
+
const oldCwd = process.cwd();
|
|
666
|
+
process.chdir(tmpDir);
|
|
667
|
+
try {
|
|
668
|
+
const server = new McpServer("/test-verify-skip-outside", "Verify Skip Outside");
|
|
669
|
+
registerDevTools(server);
|
|
670
|
+
// Broken JS placed under tests/ — must NOT be checked
|
|
671
|
+
const { result } = callTool(server, "file_write", {
|
|
672
|
+
path: "tests/foo.js",
|
|
673
|
+
content: "const x = ;\n",
|
|
674
|
+
});
|
|
675
|
+
const r = result as { written?: string; import_error?: string } | null;
|
|
676
|
+
assert(
|
|
677
|
+
"skips files outside src/ — no import_error attached",
|
|
678
|
+
r !== null && r.import_error === undefined && typeof r.written === "string",
|
|
679
|
+
`Got: ${JSON.stringify(result)}`,
|
|
680
|
+
);
|
|
681
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
682
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
683
|
+
assert(
|
|
684
|
+
"skips files outside src/ — no write.import_failed in agent.log",
|
|
685
|
+
!logContent.includes("write.import_failed"),
|
|
686
|
+
`Log content: ${logContent.slice(0, 400)}`,
|
|
687
|
+
);
|
|
688
|
+
} finally {
|
|
689
|
+
process.chdir(oldCwd);
|
|
690
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
393
694
|
// ── Summary ──────────────────────────────────────────────────
|
|
394
695
|
|
|
395
696
|
console.log(`\nMCP Tests: ${pass} passed, ${fail} failed`);
|