From 85123b7755e09ea4674df633d8818845b454d835 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 8 Jun 2026 20:34:16 -0400 Subject: [PATCH] fixed regressed parsing, tool sanitation --- bun.lock | 308 +++++++++ index.ts | 32 +- package.json | 6 +- src/dag.ts | 14 +- src/executor.ts | 31 +- src/parser.ts | 51 +- tests/dag-construction.test.ts | 674 +++++++++++++++++++ tests/helpers.ts | 30 + tests/parser-dag.test.ts | 1119 ++++++++++++++++++++++++++++++++ tests/parser-formats.test.ts | 1030 +++++++++++++++++++++++++++++ 10 files changed, 3262 insertions(+), 33 deletions(-) create mode 100644 bun.lock create mode 100644 tests/dag-construction.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/parser-dag.test.ts create mode 100644 tests/parser-formats.test.ts diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..644c031 --- /dev/null +++ b/bun.lock @@ -0,0 +1,308 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@mikefreno/ralpi", + "dependencies": { + "yaml": "^2.4.0", + }, + "devDependencies": { + "@types/node": "^20.0.0", + "bun-types": "^1.3.14", + "typescript": "^5.3.0", + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.974.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-SMNfLCU/41xxfFaC5Slwy8V/f1FRhakvyeeMeDeIxqNF0DzhDlXsXnJDELJYke1EtnJbfzfilW7tvulGfxMY6A=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZPsnLyrpDRmojKrBbJykASyLLVFkjyD+fWATeSuYgaqablijGOzxPxEKyrwUvNg+bgSQ7PkW2FTu65Xco19Gag=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1XdgHDIPbARHuzZXM7ouzIbSUZFU9dTi9k+ryMhiZU4QCam4dvwOyUEFjEHNxAZehCYUIOmsSUZ2un6BIgUkWg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/credential-provider-env": "^3.972.45", "@aws-sdk/credential-provider-http": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.50", "@aws-sdk/credential-provider-process": "^3.972.45", "@aws-sdk/credential-provider-sso": "^3.972.50", "@aws-sdk/credential-provider-web-identity": "^3.972.50", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-f8sRTVyM+9BbzQKPlUP9dVVpgNEu65jFckNAAGzRfCrlaSi5AWUbCKEHIMcIYokv8pWblSKEqHkqKYZtwINnhw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-NHHsKoMhw6UylSU0XDnDc87+IQW8tRBTIe6vnOX12GSIlBDtoce6bSzONleIglCyu8d3H9bmTSfk+sIN5yh3WA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.53", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.45", "@aws-sdk/credential-provider-http": "^3.972.47", "@aws-sdk/credential-provider-ini": "^3.972.51", "@aws-sdk/credential-provider-process": "^3.972.45", "@aws-sdk/credential-provider-sso": "^3.972.50", "@aws-sdk/credential-provider-web-identity": "^3.972.50", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-z/JJ8Qvf2GiTn4bw+x8k7wQjxmPpNsiwZ7ls/h1cZHikrSpS0+65lB+lafnXZlxv1lqH4k6rQwh+2UsycC662g=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-QMJXjTGLmHE4Ie03T5H4hHOLfcvMc9DaODO6b5dgte3S8ECf5bBuHUJW4cQREcYZyRkOU8iymqtiBxqF4icxZg=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/token-providers": "3.1064.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pQ9ww4G53gwHlon1NMz25JhaBo13E9Jv+VVgjh39C/yzvby+xhSnEOb+VDYShKNCh1TbttMF/5CFCHkZrIqOcA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9DbaPaT2aMbz18wtSpq9HVBErjBQwxykqTFgG6n8Bn05GN68mITz+G1869ekYx0mVT/BDjETj5czz/3cPgLwxA=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-V/IgUogQm/NSGlNglDCkREirQXgjyrrq64vPt5qcRTGhEJJwPcUB3RyYE6iZ63WNGp4Ezc+JtVRA4QlSbhDvVQ=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.18", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.19", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xBWrodBvW5SHCZV11UZUJG0pSHkLCEREIBoNbff1C1sacOUCmxJnTCPE80sCGLCtqgXg98I2MQJe2z28tcZSsw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.33", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.7", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.29", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.0", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-jQOtYjRGZ7+XC/olw9euLd2V03vkAPO8u0sSnQoLbyOQZz66dEBZrklTESk34Sf3AaeBSua28wjZR48ch1aXJQ=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-D/2aDoe9vcCbqAztALQcKkdqXGuaQcqAzLm8LfUhNaorwoIHkwnaAuDVlo+OkF5clpEwS8Z1bk2o8NiSrwEdsA=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.0", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.0", "@earendil-works/pi-ai": "^0.79.0", "@earendil-works/pi-tui": "^0.79.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-pZoXk65vFR3dAzzmPNWEX61aHnT6+BaVhTyFDQAs1DyumaMeWpvzRV9ZrGxqlbVLwhrq+0LnXbaqDAFkhe2+MQ=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" } }, "sha512-qAQWMruW7YKbk2hPcTD4INtXfvIySXifbPQ+mFY5j3J8yf2tfElkh+gGPuBvgPKPT0z9WiAkd7iySCuQq0txuQ=="], + + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="], + + "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + + "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], + + "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + + "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1064.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-sjI+iA4JtgeckBgKwPQF7KzWillRoNDmtpiM0TRa0syiAKFHKUSf84kPXSO3+gA7aMMSxrcxzOM2oPSecaJvEA=="], + + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], + + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + } +} diff --git a/index.ts b/index.ts index bf9babd..d5fe0bf 100644 --- a/index.ts +++ b/index.ts @@ -194,7 +194,11 @@ async function executePlanBatches( // In parallel mode, rebuild the plan to filter out newly blocked tasks if (mode === "parallel") { - const completed = new Set(progress.getCompletedTaskIds()); + // Use buildCompletedSet to include file-based [x] completions + // (progress.getCompletedTaskIds() only knows about tasks completed + // during THIS execution session — tasks that were already [x] in the + // file before the run started would be re-included and re-executed). + const completed = buildCompletedSet(progress, project); const newPlan = buildExecutionPlan( project, completed, @@ -354,17 +358,33 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { } }; + /** + * Strip control characters and newlines from a display label so it + * does not break TUI layout (tree branches, text width calculation). + */ + function sanitizeLabel(s: string): string { + return s + .replace(/\r?\n/g, " ") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") + .trim(); + } + /** Format a tool call argument into a short label. */ function formatToolLabel(name: string, args: unknown): string { const a = args as Record | undefined; if (!a) return name; - if (name === "bash") return String(a.command ?? "").slice(0, 70); + if (name === "bash") + return sanitizeLabel(String(a.command ?? "").slice(0, 70)); if (name === "write" || name === "read" || name === "edit") - return String(a.path ?? "").slice(0, 60); + return sanitizeLabel(String(a.path ?? "").slice(0, 60)); if (name === "grep") - return `${a.pattern ?? "?"} — ${String(a.path ?? "").slice(0, 40)}`; - if (name === "find") return `${a.path ?? "."} — ${a.glob ?? "*"}`; - if (name === "ls") return String(a.path ?? ".").slice(0, 60); + return sanitizeLabel( + `${a.pattern ?? "?"} — ${String(a.path ?? "").slice(0, 40)}`, + ); + if (name === "find") + return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`); + if (name === "ls") + return sanitizeLabel(String(a.path ?? ".").slice(0, 60)); return name; } diff --git a/package.json b/package.json index 74b4bb3..e0d5ef2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mikefreno/ralpi", - "version": "0.2.0", + "version": "0.2.2", "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "keywords": [ "pi-package", @@ -31,7 +31,8 @@ ], "scripts": { "typecheck": "tsc --noEmit", - "prepublishOnly": "tsc --noEmit" + "prepublishOnly": "tsc --noEmit", + "test": "bun test" }, "engines": { "bun": ">=1.1.0" @@ -59,6 +60,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", + "bun-types": "^1.3.14", "typescript": "^5.3.0" } } diff --git a/src/dag.ts b/src/dag.ts index 3ec29b1..535abd5 100644 --- a/src/dag.ts +++ b/src/dag.ts @@ -24,7 +24,7 @@ export function getBlockedTasks( for (const task of pendingTasks) { if (blocked.has(task.id)) continue; const deps = task.dependencies || []; - if (deps.some((dep) => failedTaskIds.has(dep))) { + if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) { blocked.add(task.id); changed = true; } @@ -46,9 +46,15 @@ export function buildExecutionPlan( parallelGroup?: number, failedTaskIds: Set = new Set(), ): ExecutionPlan { - // Filter out already completed tasks - const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); - const skippedTasks = project.tasks.filter((t) => completed.has(t.id)); + // Filter out already completed AND failed tasks + // Failed tasks should not be re-scheduled — they're only re-attempted + // via the retry mechanism inside executeTask, not via the DAG. + const pendingTasks = project.tasks.filter( + (t) => !completed.has(t.id) && !failedTaskIds.has(t.id), + ); + const skippedTasks = project.tasks.filter( + (t) => completed.has(t.id) || failedTaskIds.has(t.id), + ); // With explicitly declared parallel groups, all groups are independent. // Since there are no cross-group dependencies by definition, standard diff --git a/src/executor.ts b/src/executor.ts index a56d6ca..52ba45d 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -866,6 +866,20 @@ function sleep(ms: number): Promise { // ─── Tool Call Formatting ──────────────────────────────────────────────── +/** + * Strip control characters and newlines from a display label so it + * does not break TUI layout (tree branches, text width calculation). + */ +function sanitizeLabel(s: string): string { + // Replace newlines/carriage returns with spaces (multi-line commands + // must fit on a single tree-branch line), then strip ASCII control + // characters except \t (which is harmless) and keep printable chars. + return s + .replace(/\r?\n/g, " ") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") + .trim(); +} + /** * Format a tool call argument into a short label. */ @@ -873,21 +887,20 @@ function formatToolArg(name: string, args: unknown): string { const a = args as Record; switch (name) { case "bash": - return truncateMiddle(String(a.command ?? ""), 70); + return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70)); case "write": case "read": - return truncateMiddle(String(a.path ?? ""), 60); + return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60)); case "edit": - return truncateMiddle(String(a.path ?? ""), 60); + return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60)); case "grep": - return `${a.pattern ?? "?"} — ${truncateMiddle( - String(a.path ?? ""), - 40, - )}`; + return sanitizeLabel( + `${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`, + ); case "find": - return `${a.path ?? "."} — ${a.glob ?? "*"}`; + return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`); case "ls": - return truncateMiddle(String(a.path ?? "."), 60); + return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60)); default: return name; } diff --git a/src/parser.ts b/src/parser.ts index 60fa2f2..56f687c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -44,8 +44,27 @@ export function parseTaskFile(filePath: string): Project { // ─── Fio Format Parser ─────────────────────────────────────────────────────── +/** Match both markdown heading (## Dependencies) and plain heading (Dependencies). */ +const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m; +/** Match both markdown heading (## Tasks) and plain heading (Tasks). */ +const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m; +/** Match other markdown headings (## Something). */ +const ANY_MD_HEADING_RE = /^##\s/; +/** + * Detect a plain (non-markdown) section heading like "Exit criteria". + * A plain heading must: + * - Start with a letter + * - Contain only letters and spaces + * - Have no colons (avoids matching "Objective:" and "Status legend:") + * - Not be a task/dep line (doesn't start with "-") + */ +function isPlainSectionHeader(line: string): boolean { + const trimmed = line.trim(); + return trimmed.length > 0 && /^[A-Za-z][A-Za-z\s]*$/.test(trimmed); +} + function hasDependenciesSection(content: string): boolean { - return /^##\s+Dependencies\s*$/m.test(content); + return DEP_HEADING_RE.test(content); } function parseFioFormat( @@ -61,20 +80,21 @@ function parseFioFormat( let inDeps = false; for (const line of lines) { - if (/^##\s+Tasks\s*$/m.test(line)) { + if (TASK_HEADING_RE.test(line)) { inTasks = true; inDeps = false; continue; } - if (/^##\s+Dependencies\s*$/m.test(line)) { + if (DEP_HEADING_RE.test(line)) { inTasks = false; inDeps = true; continue; } + // Reset state on any other section heading — both ##-style and plain if ( - /^##\s/.test(line) && - !/^##\s+Tasks/.test(line) && - !/^##\s+Dependencies/.test(line) + (ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) && + !TASK_HEADING_RE.test(line) && + !DEP_HEADING_RE.test(line) ) { inTasks = false; inDeps = false; @@ -272,12 +292,14 @@ function parseFioFormat( } } - // Extract exit criteria + // Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria const exitCriteria: string[] = []; - const exitIdx = lines.findIndex((l) => /^##\s+Exit\s+Criteria/i.test(l)); + const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i; + const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l)); if (exitIdx >= 0) { for (let i = exitIdx + 1; i < lines.length; i++) { - if (/^##\s/.test(lines[i])) break; + // Stop at any new section heading (##-style or plain) + if (/^##\s/.test(lines[i]) || isPlainSectionHeader(lines[i])) break; const m = lines[i].match(/^-\s+(.+)$/); if (m) exitCriteria.push(m[1].trim()); } @@ -419,9 +441,14 @@ export function updateTaskInFile( const char = statusToChar(status); // Strategy 1: Fio numbered format — match by explicit task ID in the file - // Try both padded (01) and raw (1) variations - const rawId = parseInt(taskId, 10).toString(); - const idPatterns = new Set([escapeRegex(taskId), escapeRegex(rawId)]); + // Try both padded (01) and raw (1) variations. + // When the task ID is already zero-padded (e.g., "01"), skip the raw ID + // to avoid partial matches ("1" matching the second digit of "01"). + const idPatterns = new Set([escapeRegex(taskId)]); + if (!taskId.startsWith("0")) { + const rawId = parseInt(taskId, 10).toString(); + idPatterns.add(escapeRegex(rawId)); + } for (const idPattern of idPatterns) { const fioRegex = new RegExp( diff --git a/tests/dag-construction.test.ts b/tests/dag-construction.test.ts new file mode 100644 index 0000000..7c8b4e8 --- /dev/null +++ b/tests/dag-construction.test.ts @@ -0,0 +1,674 @@ +/// +import { describe, it, expect } from "bun:test"; +import type { Project, Task } from "../src/types"; +import { + buildExecutionPlan, + buildSequentialPlan, + getBlockedTasks, + detectCycles, + getCriticalPath, + formatDependencyChain, + formatExecutionPlan, +} from "../src/dag"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeProject(overrides?: Partial): Project { + return { + tasks: [], + dependencies: {}, + sourcePath: "/tmp/test.md", + sourceDir: "/tmp", + ...overrides, + }; +} + +function task( + id: string, + dependencies: string[] = [], + status: Task["status"] = "pending", + parallelGroup?: number, +): Task { + return { id, title: `Task ${id}`, status, dependencies, parallelGroup }; +} + +function tasksFrom(...args: Task[]): Task[] { + return args; +} + +// ─── Basic DAG Construction ────────────────────────────────────────────────── + +describe("buildExecutionPlan (Kahn's algorithm)", () => { + it("handles empty task list", () => { + const project = makeProject({ tasks: [] }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toEqual([]); + expect(plan.totalTasks).toBe(0); + }); + + it("puts all root tasks in batch 0", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02"), task("03")), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(1); + expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual([ + "01", + "02", + "03", + ]); + }); + + it("builds correct linear dependency chain", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["02"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["04"]); + }); + + it("groups parallelizable tasks in the same batch", () => { + // Diamond: 01 -> 02, 03 -> 04 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Batch 0: [01], Batch 1: [02, 03], Batch 2: [04] + expect(plan.batches).toHaveLength(3); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]); + }); + + it("assigns correct batchIndex values", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches[0].batchIndex).toBe(0); + expect(plan.batches[1].batchIndex).toBe(1); + expect(plan.batches[2].batchIndex).toBe(2); + }); + + it("skips completed tasks and includes them in skippedTasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", [], "completed"), + task("02", ["01"]), + task("03", ["02"]), + ), + }); + const plan = buildExecutionPlan(project, new Set(["01"])); + expect(plan.totalTasks).toBe(2); + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches).toHaveLength(2); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]); + }); + + it("throws on dependency cycle", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", ["03"]), + task("02", ["01"]), + task("03", ["02"]), + ), + }); + expect(() => buildExecutionPlan(project, new Set())).toThrow( + /dependency cycle/i, + ); + }); + + it("blocks tasks that depend on failed tasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + ), + }); + const plan = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // 01 is excluded from pending (failed). 02, 03, 04 are pending but + // transitively blocked — they don't appear in batches. + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.totalTasks).toBe(3); // 02, 03, 04 are pending but blocked + expect(plan.batches).toHaveLength(0); + }); + + it("blocks immediate dependents when task fails", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04"), // independent + ), + }); + const plan = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // 01 is excluded from pending (failed). 02, 03 are pending but blocked + // (depend on 01). 04 is independent and ready. + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]); + }); +}); + +// ─── Complex DAGs ─────────────────────────────────────────────────────────── + +describe("Complex DAG batching", () => { + it("builds the OAuth PRD example correctly", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["01"]), + task("05", ["03", "04"]), + task("06", ["03", "04"]), + task("07", ["03"]), + task("08", ["05", "06", "07"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Expected batches: [01], [02,04], [03], [05,06,07], [08] + expect(plan.batches).toHaveLength(5); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "04"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]); + expect(plan.batches[3].tasks.map((t) => t.id).sort()).toEqual([ + "05", + "06", + "07", + ]); + expect(plan.batches[4].tasks.map((t) => t.id)).toEqual(["08"]); + }); + + it("builds the Design Token PRD example correctly", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + task("05", ["04", "01"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Expected batches: [01], [02,03], [04], [05] + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["05"]); + }); + + it("handles a 3-tier diamond", () => { + // 01 + // / \ + // 02 03 + // / \ / \ + // 04 05 06 + // \ | / + // 07 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02"]), + task("05", ["02", "03"]), + task("06", ["03"]), + task("07", ["04", "05", "06"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([ + "04", + "05", + "06", + ]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["07"]); + }); + + it("handles a wide fan-out with delayed convergence", () => { + // 01 -> 02,03,04,05,06 + // 02,03 -> 07 + // 04,05 -> 08 + // 06 -> 09 + // 07,08,09 -> 10 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["01"]), + task("05", ["01"]), + task("06", ["01"]), + task("07", ["02", "03"]), + task("08", ["04", "05"]), + task("09", ["06"]), + task("10", ["07", "08", "09"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual([ + "02", + "03", + "04", + "05", + "06", + ]); + expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([ + "07", + "08", + "09", + ]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["10"]); + }); + + it("handles multiple independent subgraphs", () => { + // Two completely independent chains: + // Chain A: 01 -> 02 -> 03 + // Chain B: 04 -> 05 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04"), + task("05", ["04"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Batch 0: [01, 04] (both roots) + // Batch 1: [02, 05] + // Batch 2: [03] + expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "04"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "05"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]); + }); + + it("batches tasks respecting fan-in convergence", () => { + // 01 -> 03, 02 -> 03 (03 depends on both 01 AND 02) + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02"), task("03", ["01", "02"])), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "02"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]); + }); +}); + +// ─── Sequential Plan ───────────────────────────────────────────────────────── + +describe("buildSequentialPlan", () => { + it("puts each task in its own batch", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])), + }); + const plan = buildSequentialPlan(project, new Set()); + expect(plan.batches).toHaveLength(3); + plan.batches.forEach((b, i) => { + expect(b.tasks).toHaveLength(1); + expect(b.batchIndex).toBe(i); + }); + }); + + it("skips completed tasks and blocks transitively failed tasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04"), + ), + }); + const plan = buildSequentialPlan(project, new Set(["01"]), new Set(["01"])); + // 01 failed => 02, 03 blocked. 04 independent, runs. + expect(plan.skippedTasks.map((t) => t.id).sort()).toEqual([ + "01", + "02", + "03", + ]); + expect(plan.totalTasks).toBe(3); + }); + + it("maintains task order in sequential batches", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])), + }); + const plan = buildSequentialPlan(project, new Set()); + expect(plan.batches.map((b) => b.tasks[0].id)).toEqual(["01", "02", "03"]); + }); +}); + +// ─── getBlockedTasks ───────────────────────────────────────────────────────── + +describe("getBlockedTasks", () => { + it("returns direct dependents of failed tasks", () => { + const pending = tasksFrom(task("01"), task("02", ["01"]), task("03")); + const blocked = getBlockedTasks(pending, new Set(["01"])); + expect([...blocked]).toEqual(["02"]); + }); + + it("returns transitive dependents (chain reaction)", () => { + const pending = tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + ); + const blocked = getBlockedTasks(pending, new Set(["01"])); + expect([...blocked].sort()).toEqual(["02", "03", "04"]); + }); + + it("does not affect tasks in separate subgraphs", () => { + const pending = tasksFrom( + task("01"), + task("02", ["01"]), + task("10"), + task("11", ["10"]), + ); + const blocked = getBlockedTasks(pending, new Set(["01"])); + expect([...blocked].sort()).toEqual(["02"]); + }); + + it("returns empty set when no tasks depend on failed tasks", () => { + const pending = tasksFrom(task("01"), task("02"), task("03")); + const blocked = getBlockedTasks(pending, new Set(["99"])); + expect(blocked.size).toBe(0); + }); +}); + +// ─── detectCycles ──────────────────────────────────────────────────────────── + +describe("detectCycles", () => { + it("returns empty for acyclic graph", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["02"])), + }); + expect(detectCycles(project)).toEqual([]); + }); + + it("detects a 3-node cycle", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", ["03"]), + task("02", ["01"]), + task("03", ["02"]), + ), + }); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("detects a self-loop", () => { + const project = makeProject({ + tasks: tasksFrom(task("01", ["01"])), + }); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("detects cycle in disconnected subgraph", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), // isolated + task("02", ["03"]), + task("03", ["02"]), // cycle + ), + }); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("returns empty for graph with only diamond patterns", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + }); + expect(detectCycles(project)).toEqual([]); + }); +}); + +// ─── getCriticalPath ───────────────────────────────────────────────────────── + +describe("getCriticalPath", () => { + it("returns the longest path through the DAG", () => { + // 01 -> 02 -> 03 -> 04 (long = 4) + // 01 -> 05 -> 04 (short = 3) + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03", "05"]), + task("05", ["01"]), + ), + }); + const path = getCriticalPath(project); + expect(path.length).toBe(4); + expect(path[0].id).toBe("01"); + expect(path[path.length - 1].id).toBe("04"); + }); + + it("returns single-node path for roots", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02"), task("03")), + }); + const path = getCriticalPath(project); + expect(path.length).toBe(1); + }); + + it("handles complex branching by picking the longest chain", () => { + // 01 -> 02 -> 03 -> 04 -> 05 (long = 5) + // 01 -> 06 -> 05 (short = 3) + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + task("05", ["04", "06"]), + task("06", ["01"]), + ), + }); + const path = getCriticalPath(project); + // Should pick 01 -> 02 -> 03 -> 04 -> 05 (length 5) + expect(path.length).toBe(5); + expect(path.map((t) => t.id)).toEqual(["01", "02", "03", "04", "05"]); + }); +}); + +// ─── formatDependencyChain ─────────────────────────────────────────────────── + +describe("formatDependencyChain", () => { + it("renders a simple tree", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"])), + }); + const formatted = formatDependencyChain(project); + expect(formatted).toContain("01"); + expect(formatted).toContain("02"); + }); + + it("mentions root tasks", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02")), + }); + const formatted = formatDependencyChain(project); + expect(formatted).toMatch(/01.*root|root.*01/i); + }); + + it("handles empty task list", () => { + const project = makeProject({ tasks: [] }); + const formatted = formatDependencyChain(project); + expect(formatted).toContain("no tasks"); + }); + + it("shows orphan tasks when dependencies reference non-existent IDs", () => { + const project = makeProject({ + tasks: tasksFrom(task("01", ["99"])), + }); + const formatted = formatDependencyChain(project); + expect(formatted).toMatch(/orphan|unreached/i); + }); +}); + +// ─── formatExecutionPlan ───────────────────────────────────────────────────── + +describe("formatExecutionPlan", () => { + it("displays task counts and batches", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"])), + }); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan); + expect(formatted).toContain("Total tasks"); + expect(formatted).toContain("Batches"); + expect(formatted).toContain("01"); + expect(formatted).toContain("02"); + }); + + it("shows skipped tasks", () => { + const project = makeProject({ + tasks: tasksFrom(task("01", [], "completed"), task("02", ["01"])), + }); + const plan = buildExecutionPlan(project, new Set(["01"])); + const formatted = formatExecutionPlan(plan); + expect(formatted).toContain("completed"); + }); + + it("shows parallel group annotations when provided", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])), + parallelGroups: [{ index: 0, label: "UI sprint", taskIds: ["02", "03"] }], + }); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan, project.parallelGroups); + expect(formatted).toContain("UI sprint"); + }); +}); + +// ─── Group-Aware Batching ──────────────────────────────────────────────────── + +describe("Parallel group batching", () => { + it("builds batches when parallel groups are defined", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + parallelGroups: [ + { index: 0, label: "Frontend", taskIds: ["01", "02", "03", "04"] }, + ], + }); + // Should route through buildGroupAwareBatches + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches.length).toBeGreaterThan(0); + }); + + it("respects intra-group dependencies in parallel groups", () => { + // Tasks: 01 -> 02, 01 -> 03, 02 -> 04, 03 -> 04 + // With parallel groups, there are no cross-group dependencies by definition. + // Intra-group deps are respected by Kahn's algorithm. + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + parallelGroups: [ + { index: 0, label: "All", taskIds: ["01", "02", "03", "04"] }, + ], + }); + const plan = buildExecutionPlan(project, new Set()); + // Batch 0: [01], Batch 1: [02, 03], Batch 2: [04] + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]); + }); +}); + +// ─── Real-World Scenario: Resume with completed tasks ─────────────────────── + +describe("Real-world resume scenarios", () => { + it("buildExecutionPlan correctly excludes file-based [x] completions", () => { + // Design Token PRD resume: 01,02,03 [x] in file, 04 [~], 05 [ ] + const project = makeProject({ + tasks: tasksFrom( + task("01", [], "completed"), + task("02", ["01"], "completed"), + task("03", ["01"], "completed"), + task("04", ["02", "03"], "in_progress"), + task("05", ["04", "01"], "pending"), + ), + }); + // buildCompletedSet in index.ts produces {01, 02, 03} from file + progress + // This simulates what happens after buildCompletedSet is called + const completedFromFile = new Set( + project.tasks.filter((t) => t.status === "completed").map((t) => t.id), + ); + const plan = buildExecutionPlan(project, completedFromFile); + + // Only 04 and 05 should be pending + expect(plan.totalTasks).toBe(2); + expect(plan.batches).toHaveLength(2); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["05"]); + }); + + it("skipsTasks includes both progress-completed and file-completed tasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", [], "completed"), + task("02", ["01"], "pending"), + ), + }); + // Simulate: 01 completed in file AND in progress + const plan = buildExecutionPlan(project, new Set(["01"])); + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..03c11cb --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,30 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Create a temporary directory for test files. + * Returns the path and a cleanup function. + */ +export function tempDir(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-test-")); + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +/** + * Write content to a temp markdown file and return its path. + */ +export function writeTaskFile( + dir: string, + name: string, + content: string, +): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content, "utf-8"); + return filePath; +} diff --git a/tests/parser-dag.test.ts b/tests/parser-dag.test.ts new file mode 100644 index 0000000..63603e8 --- /dev/null +++ b/tests/parser-dag.test.ts @@ -0,0 +1,1119 @@ +/** + * Comprehensive tests for ralpi's parser (dependency formats) and DAG construction. + * + * Run: bun test tests/parser-dag.test.ts + * + * Covers all supported dependency declaration formats: + * - Arrow notation (->, →) — single, multi-target, multi-source, chained + * - Natural language "depends on" / "depend on" / "also depends on" + * - "must be done before" — single→multi, multi→single, multi→multi + * - "can be done in parallel" — with and without labels + * - Mixed formats in one file + * - DAG construction (Kahn's algorithm) — batching, cycle detection, critical path + * - buildCompletedSet integration + * - Blocked tasks (transitive) + * - Edge cases, negative tests + */ +import { describe, test, expect } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parseTaskFile } from "../src/parser"; +import { + buildExecutionPlan, + buildSequentialPlan, + detectCycles, + getBlockedTasks, + getCriticalPath, + getReadyTasks, +} from "../src/dag"; +import type { Task, Project, ExecutionPlan } from "../src/types"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Parse a markdown string as if it were a task file, returning the Project. */ +function parseMD(content: string, name = "test-prd.md"): Project { + const dir = fs.mkdtempSync("/tmp/ralpi-test-"); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content, "utf-8"); + try { + return parseTaskFile(filePath); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** Build an execution plan from a Project, marking specified IDs as completed. */ +function plan( + project: Project, + completedIds: string[] = [], + failedIds: string[] = [], +): ExecutionPlan { + return buildExecutionPlan( + project, + new Set(completedIds), + undefined, + new Set(failedIds), + ); +} + +/** Extract batch IDs for easy assertion: [[id1,id2], [id3], ...] */ +function batchIds(plan: ExecutionPlan): string[][] { + return plan.batches.map((b) => b.tasks.map((t) => t.id).sort()); +} + +/** Find a task by ID in a Project. */ +function findTask(project: Project, id: string): Task { + const t = project.tasks.find((t) => t.id === id); + if (!t) throw new Error(`Task ${id} not found`); + return t; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ARROW NOTATION +// ───────────────────────────────────────────────────────────────────────────── + +describe("Arrow notation (->)", () => { + test("single arrow: 01 -> 02", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +## Dependencies +01 -> 02`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("multi-target: 01 -> 02,03,06", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +- [ ] 03 — task-c +- [ ] 06 — task-f +## Dependencies +01 -> 02,03,06`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + expect(findTask(project, "06").dependencies).toEqual(["01"]); + }); + + test("multi-source: 05,07,08 -> 13", () => { + const md = `# Test +## Tasks +- [ ] 05 — task-e +- [ ] 07 — task-g +- [ ] 08 — task-h +- [ ] 13 — task-m +## Dependencies +05, 07, 08 -> 13`; + const project = parseMD(md); + expect(findTask(project, "13").dependencies).toEqual(["05", "07", "08"]); + }); + + test("chained: 03 -> 04 -> 05", () => { + const md = `# Test +## Tasks +- [ ] 03 — task-c +- [ ] 04 — task-d +- [ ] 05 — task-e +## Dependencies +03 -> 04 -> 05`; + const project = parseMD(md); + expect(findTask(project, "04").dependencies).toEqual(["03"]); + expect(findTask(project, "05").dependencies).toEqual(["04"]); + }); + + test("with markdown list prefix: - 01 -> 02,03", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +- [ ] 03 — task-c +## Dependencies +- 01 -> 02,03`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + }); + + test("unicode arrow (→): 01 → 02", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +## Dependencies +01 → 02`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("chained with unicode arrows: A → B → C", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — build +- [ ] 03 — deploy +## Dependencies +01 → 02 → 03`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["02"]); + }); + + test("multi-source multi-target: 01,02 -> 03,04", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +## Dependencies +01, 02 -> 03, 04`; + const project = parseMD(md); + expect(findTask(project, "03").dependencies).toEqual(["01", "02"]); + expect(findTask(project, "04").dependencies).toEqual(["01", "02"]); + }); + + test("unpadded task IDs still pad to 2 digits", () => { + const md = `# Test +## Tasks +- [ ] 1 — task-a +- [ ] 2 — task-b +- [ ] 3 — task-c +## Dependencies +1 -> 2, 3`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + }); + + test("arrow with parenthetical comment is stripped", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +- 01 -> 02, 03 (core dependency)`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + }); + + test("non-numeric text after arrow is ignored", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> some-text-here`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// NATURAL LANGUAGE "depends on" +// ───────────────────────────────────────────────────────────────────────────── + +describe('Natural language "depends on"', () => { + test("single task depends on single", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +02 depends on 01`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("task depends on multiple: 13 depends on 17, 18, 19, 20", () => { + const md = `# Test +## Tasks +- [ ] 13 — task-m +- [ ] 17 — task-q +- [ ] 18 — task-r +- [ ] 19 — task-s +- [ ] 20 — task-t +## Dependencies +13 depends on 17, 18, 19, 20`; + const project = parseMD(md); + expect(findTask(project, "13").dependencies).toEqual([ + "17", + "18", + "19", + "20", + ]); + }); + + test('multiple tasks depend on one: "depend on" (plural)', () => { + const md = `# Test +## Tasks +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +- [ ] 05 — e +## Dependencies +04, 05 depend on 02, 03`; + const project = parseMD(md); + expect(findTask(project, "04").dependencies).toEqual(["02", "03"]); + expect(findTask(project, "05").dependencies).toEqual(["02", "03"]); + }); + + test("also depends on", () => { + const md = `# Test +## Tasks +- [ ] 05 — e +- [ ] 06 — f +- [ ] 08 — h +## Dependencies +08 also depends on 05, 06`; + const project = parseMD(md); + expect(findTask(project, "08").dependencies).toEqual(["05", "06"]); + }); + + test("with markdown list prefix", () => { + const md = `# Test +## Tasks +- [ ] 13 — m +- [ ] 17 — q +- [ ] 18 — r +- [ ] 19 — s +## Dependencies +- 13 depends on 17, 18, 19`; + const project = parseMD(md); + expect(findTask(project, "13").dependencies).toEqual(["17", "18", "19"]); + }); + + test("with parenthetical description", () => { + const md = `# Test +## Tasks +- [ ] 21 — setup-db +- [ ] 22 — write-queries +- [ ] 23 — build-api +## Dependencies +- 22 depends on 21 (database schema must exist) +- 23 depends on 22 (API builds on queries)`; + const project = parseMD(md); + expect(findTask(project, "22").dependencies).toEqual(["21"]); + expect(findTask(project, "23").dependencies).toEqual(["22"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// "must be done before" +// ───────────────────────────────────────────────────────────────────────────── + +describe('"must be done before"', () => { + test("single must be done before multiple", () => { + const md = `# Test +## Tasks +- [ ] 21 — backend-foundation +- [ ] 22 — api-endpoints +- [ ] 23 — database-migrations +- [ ] 24 — integration-tests +## Dependencies +21 must be done before 22, 23, 24`; + const project = parseMD(md); + expect(findTask(project, "21").dependencies).toEqual([]); + expect(findTask(project, "22").dependencies).toEqual(["21"]); + expect(findTask(project, "23").dependencies).toEqual(["21"]); + expect(findTask(project, "24").dependencies).toEqual(["21"]); + }); + + test("multiple must be done before single", () => { + const md = `# Test +## Tasks +- [ ] 02 — design +- [ ] 03 — review +- [ ] 04 — implement +## Dependencies +02, 03 must be done before 04`; + const project = parseMD(md); + expect(findTask(project, "04").dependencies).toEqual(["02", "03"]); + }); + + test("with markdown list prefix and parenthetical", () => { + const md = `# Test +## Tasks +- [ ] 21 — backend +- [ ] 22 — api +- [ ] 23 — db +- [ ] 24 — tests +## Dependencies +- 21 must be done before 22, 23, 24 (backend integration foundation)`; + const project = parseMD(md); + expect(findTask(project, "22").dependencies).toEqual(["21"]); + expect(findTask(project, "23").dependencies).toEqual(["21"]); + expect(findTask(project, "24").dependencies).toEqual(["21"]); + }); + + test("multi must be done before multi", () => { + const md = `# Test +## Tasks +- [ ] 01 — env +- [ ] 02 — config +- [ ] 03 — api-v1 +- [ ] 04 — api-v2 +## Dependencies +01, 02 must be done before 03, 04`; + const project = parseMD(md); + expect(findTask(project, "03").dependencies).toEqual(["01", "02"]); + expect(findTask(project, "04").dependencies).toEqual(["01", "02"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// "can be done in parallel" +// ───────────────────────────────────────────────────────────────────────────── + +describe('"can be done in parallel"', () => { + test("basic parallel group without label", () => { + const md = `# Test +## Tasks +- [ ] 02 — design +- [ ] 03 — auth +- [ ] 04 — storage +## Dependencies +02, 03, 04 can be done in parallel`; + const project = parseMD(md); + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups!.length).toBe(1); + expect(project.parallelGroups![0].taskIds.sort()).toEqual([ + "02", + "03", + "04", + ]); + expect(project.parallelGroups![0].label).toBeUndefined(); + }); + + test("parallel group with label", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +## Dependencies +01, 02, 03, 04 can be done in parallel (Play Store prep)`; + const project = parseMD(md); + expect(project.parallelGroups![0].label).toBe("Play Store prep"); + expect(project.parallelGroups![0].taskIds.sort()).toEqual([ + "01", + "02", + "03", + "04", + ]); + }); + + test("parallel group sets parallelGroup field on tasks", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01, 02, 03 can be done in parallel`; + const project = parseMD(md); + expect(findTask(project, "01").parallelGroup).toBe(0); + expect(findTask(project, "02").parallelGroup).toBe(0); + expect(findTask(project, "03").parallelGroup).toBe(0); + }); + + test("multiple parallel groups with labels", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +- [ ] 05 — e +- [ ] 06 — f +## Dependencies +01, 02 can be done in parallel (frontend) +03, 04, 05 can be done in parallel (backend) +06 depends on 01, 03`; + const project = parseMD(md); + expect(project.parallelGroups!.length).toBe(2); + expect(project.parallelGroups![0].label).toBe("frontend"); + expect(project.parallelGroups![1].label).toBe("backend"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// MIXED FORMATS in one file +// ───────────────────────────────────────────────────────────────────────────── + +describe("Mixed dependency formats", () => { + test("arrows + depends-on + must-before in one file", () => { + const md = `# iOS OAuth Sign-In + +Objective: Add Google and Apple OAuth sign-in options + +Status legend: [ ] todo, [~] in-progress, [x] done + +## Tasks +- [~] 01 — oauth-flow-research +- [~] 02 — clerkapi-oauth-methods +- [ ] 03 — authservice-oauth-methods +- [ ] 04 — oauth-button-component +- [ ] 05 — update-signin-view +- [ ] 06 — update-signup-view +- [ ] 07 — session-handling +- [ ] 08 — integration-tests + +## Dependencies +- 02 depends on 01 +- 03 depends on 02 +- 01 -> 04 +- 05 must be done before 06 +- 07 depends on 03 +- 08 depends on 05, 06, 07`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["02"]); + expect(findTask(project, "04").dependencies).toEqual(["01"]); + expect(findTask(project, "06").dependencies).toEqual(["05"]); + expect(findTask(project, "07").dependencies).toEqual(["03"]); + expect(findTask(project, "08").dependencies).toEqual(["05", "06", "07"]); + }); + + test("parallel groups mixed with dependencies", () => { + const md = `# Full Project + +## Tasks +- [ ] 01 — env-setup +- [ ] 02 — db-schema +- [ ] 03 — api-core +- [ ] 04 — frontend-shell +- [ ] 05 — auth-module +- [ ] 06 — user-dashboard +- [ ] 07 — admin-panel +- [ ] 08 — integration-tests +- [ ] 09 — deploy + +## Dependencies +01 -> 02, 03 +02 -> 05 +03 -> 05 +04 -> 06, 07 +05 -> 06, 07 +06, 07 can be done in parallel (user-facing work) +08 must be done before 09 +05 depends on 02, 03 +06 depends on 04, 05`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + expect(findTask(project, "05").dependencies).toEqual(["02", "03"]); + expect(findTask(project, "06").dependencies).toEqual(["04", "05"]); + expect(findTask(project, "07").dependencies.sort()).toEqual(["04", "05"]); + expect(findTask(project, "09").dependencies).toEqual(["08"]); + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups!.length).toBe(1); + expect(project.parallelGroups![0].taskIds.sort()).toEqual(["06", "07"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DAG CONSTRUCTION (Kahn's Algorithm) +// ───────────────────────────────────────────────────────────────────────────── + +describe("DAG construction (Kahn's algorithm)", () => { + test("simple linear chain produces sequential batches", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([["01"], ["02"], ["03"]]); + }); + + test("diamond dependency produces 3 batches", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — frontend +- [ ] 03 — backend +- [ ] 04 — integration +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 04`; + const p = plan(parseMD(md)); + // 01 first, then 02+03 in parallel, then 04 + expect(batchIds(p)).toEqual([["01"], ["02", "03"], ["04"]]); + }); + + test("fan-out: one task gates many", () => { + const md = `# Test +## Tasks +- [ ] 01 — foundation +- [ ] 02 — feature-a +- [ ] 03 — feature-b +- [ ] 04 — feature-c +## Dependencies +01 -> 02, 03, 04`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([["01"], ["02", "03", "04"]]); + }); + + test("fan-in: many converge on one", () => { + const md = `# Test +## Tasks +- [ ] 01 — data +- [ ] 02 — ui +- [ ] 03 — api +- [ ] 04 — integration +## Dependencies +01, 02, 03 -> 04`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([["01", "02", "03"], ["04"]]); + }); + + test("complex DAG with multiple dependency chains", () => { + // 01 + // / \ + // 02 03 + // | | + // 04 05 + // \ / + // 06 + // | + // 07 + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +- [ ] 05 — e +- [ ] 06 — f +- [ ] 07 — g +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 05 +04, 05 -> 06 +06 -> 07`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([ + ["01"], + ["02", "03"], + ["04", "05"], + ["06"], + ["07"], + ]); + }); + + test("no dependencies = all in one batch", () => { + // Content without ## Dependencies — uses simple checkbox parsing + // Simple checkbox assigns auto-incrementing IDs starting from "00" + const project = parseMD(`# Test\n- [ ] 01 — a\n- [ ] 02 — b\n- [ ] 03 — c`); + const p = plan(project); + // Simple checkbox: no dependencies, so all pending = all ready = one batch + expect(batchIds(p)).toEqual([["00", "01", "02"]]); + }); + + test("completed tasks are excluded from batches", () => { + const md = `# Test +## Tasks +- [x] 01 — setup (done) +- [ ] 02 — build +- [ ] 03 — test +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + // 01 is [x] in file, buildCompletedSet would include it + const p = buildExecutionPlan( + project, + new Set(["01"]), // completed + ); + expect(batchIds(p)).toEqual([["02"], ["03"]]); + expect(p.totalTasks).toBe(2); + }); + + test("all completed = empty plan", () => { + const md = `# Test +## Tasks +- [x] 01 — a +- [x] 02 — b +## Dependencies +01 -> 02`; + const project = parseMD(md); + const p = buildExecutionPlan(project, new Set(["01", "02"])); + expect(batchIds(p)).toEqual([]); + expect(p.totalTasks).toBe(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SEQUENTIAL PLAN +// ───────────────────────────────────────────────────────────────────────────── + +describe("Sequential plan", () => { + test("each batch has exactly one task", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildSequentialPlan(project, new Set()); + expect(p.batches.length).toBe(3); + for (const b of p.batches) { + expect(b.tasks.length).toBe(1); + } + }); + + test("sequential plan respects dependency order", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildSequentialPlan(project, new Set()); + expect(p.batches[0].tasks[0].id).toBe("01"); + expect(p.batches[1].tasks[0].id).toBe("02"); + expect(p.batches[2].tasks[0].id).toBe("03"); + }); + + test("sequential excludes completed tasks", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildSequentialPlan(project, new Set(["01"])); + expect(p.batches.length).toBe(2); + expect(p.batches[0].tasks[0].id).toBe("02"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CYCLE DETECTION +// ───────────────────────────────────────────────────────────────────────────── + +describe("Cycle detection", () => { + test("no cycle = empty array", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02`; + const project = parseMD(md); + expect(detectCycles(project)).toEqual([]); + }); + + test("direct cycle: A -> B -> A", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02 +02 -> 01`; + const project = parseMD(md); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + test("indirect cycle: A -> B -> C -> A", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 +02 -> 03 +03 -> 01`; + const project = parseMD(md); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + test("self-loop: A -> A", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +## Dependencies +01 -> 01`; + const project = parseMD(md); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + test("buildExecutionPlan throws on cycle", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02 +02 -> 01`; + const project = parseMD(md); + expect(() => buildExecutionPlan(project, new Set())).toThrow(/cycle/i); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// BLOCKED TASKS +// ───────────────────────────────────────────────────────────────────────────── + +describe("Blocked tasks", () => { + test("direct dependent blocked", () => { + const tasks: Task[] = [ + { id: "01", title: "a", status: "pending", dependencies: [] }, + { id: "02", title: "b", status: "pending", dependencies: ["01"] }, + { id: "03", title: "c", status: "pending", dependencies: ["02"] }, + ]; + const blocked = getBlockedTasks(tasks, new Set(["01"])); + expect(blocked.has("02")).toBe(true); + }); + + test("transitive blocking", () => { + const tasks: Task[] = [ + { id: "01", title: "a", status: "pending", dependencies: [] }, + { id: "02", title: "b", status: "pending", dependencies: ["01"] }, + { id: "03", title: "c", status: "pending", dependencies: ["02"] }, + { id: "04", title: "d", status: "pending", dependencies: [] }, + ]; + const blocked = getBlockedTasks(tasks, new Set(["01"])); + expect(blocked.has("02")).toBe(true); + expect(blocked.has("03")).toBe(true); + expect(blocked.has("04")).toBe(false); // independent + }); + + test("no failed = nothing blocked", () => { + const tasks: Task[] = [ + { id: "01", title: "a", status: "pending", dependencies: [] }, + { id: "02", title: "b", status: "pending", dependencies: ["01"] }, + ]; + const blocked = getBlockedTasks(tasks, new Set()); + expect(blocked.size).toBe(0); + }); + + test("buildExecutionPlan excludes blocked tasks from batches", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // 02 and 03 are blocked because 01 failed. 01 is excluded (failed). + // 02 and 03 remain pending but don't appear in batches. + expect(batchIds(p)).toEqual([]); + expect(p.totalTasks).toBe(2); // 02, 03 are pending (blocked) + }); + + test("blocked with diamond: failing root blocks everything downstream", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — frontend +- [ ] 03 — backend +- [ ] 04 — integration +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 04`; + const project = parseMD(md); + const p = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // All tasks are blocked since 01 failed + expect(batchIds(p)).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CRITICAL PATH +// ───────────────────────────────────────────────────────────────────────────── + +describe("Critical path", () => { + test("linear chain critical path is the whole chain", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const cp = getCriticalPath(project); + expect(cp.map((t) => t.id)).toEqual(["01", "02", "03"]); + }); + + test("diamond: critical path is the longer chain", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — short +- [ ] 03 — long +- [ ] 04 — end +## Dependencies +01 -> 02 -> 04 +01 -> 03 -> 04`; + // Both chains are length 3, so either is valid + const project = parseMD(md); + const cp = getCriticalPath(project); + expect(cp.length).toBe(3); + expect(cp[0].id).toBe("01"); + }); + + test("fan-out: critical path covers the longest depth", () => { + const md = `# Test +## Tasks +- [ ] 01 — root +- [ ] 02 — a +- [ ] 03 — b +- [ ] 04 — c +- [ ] 05 — d +## Dependencies +01 -> 02, 03, 04 +04 -> 05`; + const project = parseMD(md); + const cp = getCriticalPath(project); + // Critical path: 01 -> 04 -> 05 + expect(cp.map((t) => t.id)).toEqual(["01", "04", "05"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET READY TASKS +// ───────────────────────────────────────────────────────────────────────────── + +describe("getReadyTasks", () => { + test("root tasks are ready when nothing completed", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const ready = getReadyTasks(project, new Set()); + expect(ready.map((t) => t.id)).toEqual(["01"]); + }); + + test("task becomes ready when all deps completed", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const ready = getReadyTasks(project, new Set(["01"])); + expect(ready.map((t) => t.id)).toEqual(["02"]); + }); + + test("all independent tasks are ready", () => { + const project = parseMD(`# Test\n- [ ] 01 — a\n- [ ] 02 — b\n- [ ] 03 — c`); + const ready = getReadyTasks(project, new Set()); + expect(ready.length).toBe(3); + }); + + test("completed task is not ready", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02`; + const project = parseMD(md); + const ready = getReadyTasks(project, new Set(["01"])); + expect(ready.map((t) => t.id)).toEqual(["02"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EDGE CASES & NEGATIVE TESTS +// ───────────────────────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + test("empty dependencies section produces no deps", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual([]); + }); + + test("no dependencies section triggers simple checkbox parser", () => { + const md = `# Simple List +- [ ] 01 — task-a +- [ ] 02 — task-b +- [ ] 03 — task-c`; + const project = parseMD(md); + expect(project.tasks.length).toBe(3); + expect(project.dependencies).toEqual({}); + }); + + test("simple checkbox parser assigns sequential zero-padded IDs", () => { + const md = `- [ ] Do something +- [ ] Do something else`; + const project = parseMD(md); + expect(project.tasks[0].id).toBe("00"); + expect(project.tasks[1].id).toBe("01"); + }); + + test("line with non-dep content in ## Dependencies is ignored", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +Some explanatory text that isn't a valid dependency format. +- 01 -> 02`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("comment after 'can be done in parallel' doesn't break parsing", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01, 02, 03 can be done in parallel +01 -> 02 +# some comment`; + const project = parseMD(md); + expect(project.parallelGroups).toBeDefined(); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("exit criteria are extracted", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +## Dependencies +## Exit Criteria +- All tests pass +- Code reviewed +- Deployed to staging`; + const project = parseMD(md); + expect(project.exitCriteria).toEqual([ + "All tests pass", + "Code reviewed", + "Deployed to staging", + ]); + }); + + test("objective extracted from top heading", () => { + const md = `# iOS OAuth Sign-In + +Objective: Add Google and Apple OAuth sign-in options + +## Tasks +- [ ] 01 — research +## Dependencies`; + const project = parseMD(md); + expect(project.objective).toBe("iOS OAuth Sign-In"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SECTIONS SEPARATION — content between ## sections is ignored +// ───────────────────────────────────────────────────────────────────────────── + +describe("Section separation", () => { + test("content between ## sections doesn't leak into tasks/deps", () => { + const md = `# Test +## Tasks +- [ ] 01 — a + +Some stray text here that isn't a task + +- [ ] 02 — b + +## Dependencies +01 -> 02 + +Extra text in deps should be ignored`; + const project = parseMD(md); + expect(project.tasks.length).toBe(2); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// REAL-WORLD SCENARIO: Full PRD with complex deps +// ───────────────────────────────────────────────────────────────────────────── + +describe("Real-world PRD scenario", () => { + test("design token integration with must-before + depends-on + arrows", () => { + const md = `# iOS Design Token Integration + +Objective: Replace hardcoded color/spacing values with centralized design tokens + +## Tasks +- [x] 01 — migrate-domain-colors +- [x] 02 — replace-corner-radius +- [x] 03 — replace-system-color-leakage +- [~] 04 — replace-raw-spacing-values +- [ ] 05 — replace-border-token-props +- [ ] 06 — replace-font-token-props +- [ ] 07 — fix-component-color-variants +- [ ] 08 — fix-dark-mode-utility-class + +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 05 +04, 05 -> 06, 07, 08 +06 depends on 07`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + expect(findTask(project, "04").dependencies).toEqual(["02"]); + expect(findTask(project, "05").dependencies).toEqual(["03"]); + expect(findTask(project, "06").dependencies).toEqual(["04", "05", "07"]); + expect(findTask(project, "07").dependencies).toEqual(["04", "05"]); + expect(findTask(project, "08").dependencies).toEqual(["04", "05"]); + + // Plan with 01,02,03 completed + const p = buildExecutionPlan(project, new Set(["01", "02", "03"])); + // Batch 1: 04, 05 (both deps satisfied) + // Batch 2: 07 (depends on 04,05) + // Batch 3: 06 (depends on 04,05,07), 08 (depends on 04,05) + expect(batchIds(p)).toEqual([["04", "05"], ["07", "08"], ["06"]]); + }); +}); diff --git a/tests/parser-formats.test.ts b/tests/parser-formats.test.ts new file mode 100644 index 0000000..1c8b6c3 --- /dev/null +++ b/tests/parser-formats.test.ts @@ -0,0 +1,1030 @@ +/// +import { describe, it, expect } from "bun:test"; +import { parseTaskFile } from "../src/parser"; +import { tempDir, writeTaskFile } from "./helpers"; + +// ─── Helper ────────────────────────────────────────────────────────────────── + +/** Parse a task file from an inline template literal. */ +function parse(content: string) { + const { dir, cleanup } = tempDir(); + try { + const filePath = writeTaskFile(dir, "README.md", content); + return { project: parseTaskFile(filePath), cleanup }; + } catch (e) { + cleanup(); + throw e; + } +} + +/** Assert that task with `id` has the exact set of dependency IDs. */ +function expectDeps(content: string, id: string, expectedDeps: string[]) { + const { project, cleanup } = parse(content); + try { + const task = project.tasks.find((t) => t.id === id); + if (!task) throw new Error(`Task ${id} not found`); + expect(task.dependencies.sort()).toEqual([...expectedDeps].sort()); + } finally { + cleanup(); + } +} + +// ─── Helpers for constructing header + task table ──────────────────────────── + +const FIO_HEADER = `# Test Project + +## Tasks`; + +const FIO_FOOTER = `## Dependencies`; + +// ─── Arrow Notation Tests ──────────────────────────────────────────────────── + +describe("Arrow notation (`->`)", () => { + it("parses basic single dependency", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two + +${FIO_FOOTER} +- 01 -> 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(2); + const t1 = project.tasks.find((t) => t.id === "01")!; + const t2 = project.tasks.find((t) => t.id === "02")!; + expect(t1.dependencies).toEqual([]); + expect(t2.dependencies).toEqual(["01"]); + } finally { + cleanup(); + } + }); + + it("parses multi-target arrows (one source, many targets)", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two +- [ ] 03 — Task three + +${FIO_FOOTER} +- 01 -> 02, 03 +`, + "02", + ["01"], + ); + }); + + it("parses chained arrows (A -> B -> C)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two +- [ ] 03 — Task three + +${FIO_FOOTER} +- 01 -> 02 -> 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + } finally { + cleanup(); + } + }); + + it("parses chained arrows with multi-target forks", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two +- [ ] 03 — Task three +- [ ] 04 — Task four + +${FIO_FOOTER} +- 01 -> 02, 03 -> 04 +`; + const { project, cleanup } = parse(content); + try { + // 01 -> 02,03: 02 and 03 depend on 01 + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + // 02, 03 -> 04: 04 depends on BOTH 02 and 03 (chained multi-target fork) + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["02", "03"]); + } finally { + cleanup(); + } + }); + + it("parses unicode arrow (→)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two + +${FIO_FOOTER} +- 01 → 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("parses arrows with parenthetical descriptions", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Domain colors +- [ ] 02 — Corner radius +- [ ] 03 — Color leakage +- [ ] 04 — Raw spacing + +${FIO_FOOTER} +- 01 -> 02 (SemanticColors tokens must exist before views consume them) +- 01 -> 03 (Color tokens needed for system color replacement) +- 02 -> 04 (independent — sequential for clean git history) +- 03 -> 04 (independent — sequential for clean git history) +`, + "02", + ["01"], + ); + }); + + it("parses multi-source, multi-target arrows", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API design +- [ ] 03 — Implementation +- [ ] 04 — Review +- [ ] 05 — Merge + +${FIO_FOOTER} +- 01, 02, 03 -> 04 -> 05 +`; + const { project, cleanup } = parse(content); + try { + // 01,02,03 -> 04: 04 depends on 01,02,03 + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02", "03"]); + // 04 -> 05: 05 depends on 04 + expect(project.tasks.find((t) => t.id === "05")!.dependencies).toEqual([ + "04", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── "depends on" Format Tests ─────────────────────────────────────────────── + +describe('"depends on" format', () => { + it("parses basic 'X depends on Y'", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — OAuth research +- [ ] 02 — Clerk API + +${FIO_FOOTER} +- 02 depends on 01 +`, + "02", + ["01"], + ); + }); + + it("parses 'X depends on Y, Z' (multi-dependency)", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Sign-in design +- [ ] 02 — Sign-up design +- [ ] 03 — OAuth buttons +- [ ] 04 — Reuse buttons + +${FIO_FOOTER} +- 04 depends on 01, 02, 03 +`, + "04", + ["01", "02", "03"], + ); + }); + + it("parses 'X also depends on Y'", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Feature A +- [ ] 03 — Feature B +- [ ] 04 — Integration + +${FIO_FOOTER} +- 04 depends on 02, 03 +- 04 also depends on 01 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02", "03"]); + } finally { + cleanup(); + } + }); + + it("parses many depends-on lines forming a full DAG", () => { + const content = `${FIO_HEADER} +- [ ] 01 — OAuth research +- [ ] 02 — Clerk API methods +- [ ] 03 — AuthService methods +- [ ] 04 — OAuth button +- [ ] 05 — Update sign-in +- [ ] 06 — Update sign-up +- [ ] 07 — Callback handler +- [ ] 08 — Integration tests + +${FIO_FOOTER} +- 02 depends on 01 +- 03 depends on 02 +- 04 depends on 01 +- 05 depends on 03, 04 +- 06 depends on 03, 04 +- 07 depends on 03 +- 08 depends on 05, 06, 07 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "01")!.dependencies).toEqual( + [], + ); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "01", + ]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect( + project.tasks.find((t) => t.id === "06")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect(project.tasks.find((t) => t.id === "07")!.dependencies).toEqual([ + "03", + ]); + expect( + project.tasks.find((t) => t.id === "08")!.dependencies.sort(), + ).toEqual(["05", "06", "07"]); + } finally { + cleanup(); + } + }); +}); + +// ─── "depend on" (Plural) Format Tests ─────────────────────────────────────── + +describe('"depend on" (plural) format', () => { + it("parses 'X, Y depend on Z'", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Feature A +- [ ] 03 — Feature B + +${FIO_FOOTER} +- 02, 03 depend on 01 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("parses 'X, Y depend on Z, W' (multi-source, multi-dependency)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — API +- [ ] 03 — Feature A +- [ ] 04 — Feature B +- [ ] 05 — Integration + +${FIO_FOOTER} +- 03, 04, 05 depend on 01, 02 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "03")!.dependencies.sort(), + ).toEqual(["01", "02"]); + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02"]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["01", "02"]); + } finally { + cleanup(); + } + }); + + it("handles mixed singular/plural across different lines", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API design +- [ ] 03 — Implementation +- [ ] 04 — Tests + +${FIO_FOOTER} +- 02 depends on 01 +- 03, 04 depend on 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "02", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── "must be done before" Format Tests ───────────────────────────────────── + +describe('"must be done before" format', () => { + it("parses 'X must be done before Y'", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Setup +- [ ] 02 — Build + +${FIO_FOOTER} +- 01 must be done before 02 +`, + "02", + ["01"], + ); + }); + + it("parses 'X must be done before Y, Z' (multi-target)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Feature A +- [ ] 03 — Feature B + +${FIO_FOOTER} +- 01 must be done before 02, 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("parses 'X, Y must be done before Z' (multi-source)", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Auth +- [ ] 02 — Billing +- [ ] 03 — Dashboard + +${FIO_FOOTER} +- 01, 02 must be done before 03 +`, + "03", + ["01", "02"], + ); + }); + + it("parses 'must be done before' with parenthetical labels", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 21 — Backend integration +- [ ] 22 — API routes +- [ ] 23 — Database schema +- [ ] 24 — Frontend components + +${FIO_FOOTER} +- 21 must be done before 22, 23, 24 (backend integration foundation) +`, + "22", + ["21"], + ); + }); +}); + +// ─── Parallel Groups Format Tests ──────────────────────────────────────────── + +describe("Parallel groups format", () => { + it("parses 'X, Y can be done in parallel'", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API +- [ ] 03 — UI +- [ ] 04 — Tests + +${FIO_FOOTER} +- 01, 02, 03, 04 can be done in parallel +`; + const { project, cleanup } = parse(content); + try { + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups!).toHaveLength(1); + expect(project.parallelGroups![0].taskIds.sort()).toEqual([ + "01", + "02", + "03", + "04", + ]); + } finally { + cleanup(); + } + }); + + it("parses parallel groups with labels", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Play Store listing +- [ ] 02 — Screenshots +- [ ] 03 — Privacy policy +- [ ] 04 — Rating prompts + +${FIO_FOOTER} +- 01, 02, 03, 04 can be done in parallel (Play Store prep) +`; + const { project, cleanup } = parse(content); + try { + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups![0].label).toBe("Play Store prep"); + } finally { + cleanup(); + } + }); + + it("assigns parallelGroup index to tasks", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API +- [ ] 03 — UI + +${FIO_FOOTER} +- 01, 02, 03 can be done in parallel +`; + const { project, cleanup } = parse(content); + try { + for (const t of project.tasks) { + expect(t.parallelGroup).toBe(0); + } + } finally { + cleanup(); + } + }); +}); + +// ─── YAML Format Tests ─────────────────────────────────────────────────────── + +describe("YAML task file format", () => { + function parseYaml(content: string) { + const { dir, cleanup } = tempDir(); + const filePath = writeTaskFile(dir, "tasks.yaml", content); + return { project: parseTaskFile(filePath), cleanup }; + } + + it("parses basic YAML tasks", () => { + const content = `tasks: + - id: "01" + title: Research OAuth flows + status: pending + - id: "02" + title: Implement Clerk API methods + status: pending + depends_on: ["01"] +`; + const { project, cleanup } = parseYaml(content); + try { + expect(project.tasks).toHaveLength(2); + const t2 = project.tasks.find((t) => t.id === "02")!; + expect(t2.dependencies).toEqual(["01"]); + } finally { + cleanup(); + } + }); + + it("parses YAML with dependencies (dependencies key)", () => { + const content = `tasks: + - id: "01" + title: Foundation + - id: "02" + title: Feature A + dependencies: ["01"] + - id: "03" + title: Feature B + dependencies: ["01"] + - id: "04" + title: Integration + dependencies: ["02", "03"] +`; + const { project, cleanup } = parseYaml(content); + try { + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["02", "03"]); + } finally { + cleanup(); + } + }); + + it("parses YAML with exit criteria and objective", () => { + const content = `objective: Complete OAuth integration +exit_criteria: + - Users can sign in with Google + - Users can sign in with Apple +tasks: + - id: "01" + title: Research +`; + const { project, cleanup } = parseYaml(content); + try { + expect(project.objective).toBe("Complete OAuth integration"); + expect(project.exitCriteria).toEqual([ + "Users can sign in with Google", + "Users can sign in with Apple", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── Mixed Format Tests ────────────────────────────────────────────────────── + +describe("Mixed format files", () => { + it("handles arrow + depends-on arrows mixed in same file", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — Design +- [ ] 03 — Implement +- [ ] 04 — Test +- [ ] 05 — Deploy + +${FIO_FOOTER} +- 02 depends on 01 +- 03 -> 04 +- 04 -> 05 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual( + [], + ); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "03", + ]); + expect(project.tasks.find((t) => t.id === "05")!.dependencies).toEqual([ + "04", + ]); + } finally { + cleanup(); + } + }); + + it("handles must-be-done-before + depends-on mixed", () => { + const content = `${FIO_HEADER} +- [ ] 10 — Scaffold +- [ ] 11 — Backend +- [ ] 12 — Frontend +- [ ] 13 — Auth +- [ ] 14 — Deploy + +${FIO_FOOTER} +- 10 must be done before 11, 12 +- 13 depends on 11, 12 +- 14 depends on 13 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "11")!.dependencies).toEqual([ + "10", + ]); + expect(project.tasks.find((t) => t.id === "12")!.dependencies).toEqual([ + "10", + ]); + expect( + project.tasks.find((t) => t.id === "13")!.dependencies.sort(), + ).toEqual(["11", "12"]); + expect(project.tasks.find((t) => t.id === "14")!.dependencies).toEqual([ + "13", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── Simple Checkbox Format (Fallback) Tests ───────────────────────────────── + +describe("Simple checkbox format (fallback)", () => { + it("parses simple checkboxes when no ## Dependencies section", () => { + const content = `# Todo +- [ ] Buy groceries +- [x] Walk the dog +- [~] Do laundry +- [!] Fix bug +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(4); + expect(project.tasks[0].status).toBe("pending"); + expect(project.tasks[1].status).toBe("completed"); + expect(project.tasks[2].status).toBe("in_progress"); + expect(project.tasks[3].status).toBe("failed"); + expect(project.dependencies).toEqual({}); + } finally { + cleanup(); + } + }); +}); + +// ─── Edge Cases ────────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + it("parses a file with no dependencies section", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Solo task +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(1); + expect(project.tasks[0].dependencies).toEqual([]); + } finally { + cleanup(); + } + }); + + it("parses a file with mixed task status characters", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Pending +- [~] 02 — In progress +- [x] 03 — Completed +- [!] 04 — Failed +- [-] 05 — Skipped + +${FIO_FOOTER} +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "01")!.status).toBe("pending"); + expect(project.tasks.find((t) => t.id === "02")!.status).toBe( + "in_progress", + ); + expect(project.tasks.find((t) => t.id === "03")!.status).toBe( + "completed", + ); + expect(project.tasks.find((t) => t.id === "04")!.status).toBe("failed"); + expect(project.tasks.find((t) => t.id === "05")!.status).toBe("skipped"); + } finally { + cleanup(); + } + }); + + it("preserves exit criteria content", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task + +${FIO_FOOTER} + +## Exit Criteria +- Users can sign in with Google +- All tests pass +- No regressions +`; + const { project, cleanup } = parse(content); + try { + expect(project.exitCriteria).toBeDefined(); + expect(project.exitCriteria).toHaveLength(3); + expect(project.exitCriteria![0]).toBe("Users can sign in with Google"); + } finally { + cleanup(); + } + }); + + it("extracts the objective from the H1 heading", () => { + const content = `# iOS OAuth Sign-In + +Objective: Add Google and Apple OAuth + +## Tasks +- [ ] 01 — Research + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.objective).toBe("iOS OAuth Sign-In"); + } finally { + cleanup(); + } + }); + + it("does not confuse 'depends on' inside a parenthetical comment", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Setup +- [ ] 02 — Feature + +${FIO_FOOTER} +- 01 -> 02 (this depends on the setup being complete) +`, + "02", + ["01"], + ); + }); +}); + +// ─── Complex / Large DAG Tests ─────────────────────────────────────────────── + +describe("Complex dependency scenarios", () => { + it("parses a 20-task diamond with multiple layers", () => { + // Diamond: 01 feeds two middle layers which converge + const lines: string[] = [`${FIO_HEADER}`]; + for (let i = 1; i <= 20; i++) { + lines.push(`- [ ] ${String(i).padStart(2, "0")} — Task ${i}`); + } + lines.push("", `${FIO_FOOTER}`); + + // 01 -> 02..10 (left chain) and 01 -> 11..19 (right chain) + // 10 -> 20, 19 -> 20 + const leftIds = Array.from({ length: 9 }, (_, i) => + String(i + 2).padStart(2, "0"), + ); // 02-10 + const rightIds = Array.from({ length: 9 }, (_, i) => + String(i + 11).padStart(2, "0"), + ); // 11-19 + lines.push(`- 01 -> ${leftIds.join(", ")}`); + lines.push(`- 01 -> ${rightIds.join(", ")}`); + lines.push(`- 10, 19 -> 20`); + + const content = lines.join("\n"); + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(20); + // All left-branch tasks depend on 01 + for (const id of leftIds) { + expect(project.tasks.find((t) => t.id === id)!.dependencies).toEqual([ + "01", + ]); + } + // All right-branch tasks depend on 01 + for (const id of rightIds) { + expect(project.tasks.find((t) => t.id === id)!.dependencies).toEqual([ + "01", + ]); + } + // Task 20 depends on 10 and 19 + expect( + project.tasks.find((t) => t.id === "20")!.dependencies.sort(), + ).toEqual(["10", "19"]); + } finally { + cleanup(); + } + }); + + it("parses a multi-level fan-out/fan-in DAG", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Module A +- [ ] 03 — Module B +- [ ] 04 — Module C +- [ ] 05 — Component A1 +- [ ] 06 — Component A2 +- [ ] 07 — Component B1 +- [ ] 08 — Component B2 +- [ ] 09 — Component C1 +- [ ] 10 — Integration A +- [ ] 11 — Integration B +- [ ] 12 — Integration C +- [ ] 13 — System test +- [ ] 14 — Deploy + +${FIO_FOOTER} +- 01 -> 02, 03, 04 +- 02 -> 05, 06 +- 03 -> 07, 08 +- 04 -> 09 +- 05, 06 -> 10 +- 07, 08 -> 11 +- 09 -> 12 +- 10, 11, 12 -> 13 +- 13 -> 14 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "10")!.dependencies.sort(), + ).toEqual(["05", "06"]); + expect( + project.tasks.find((t) => t.id === "11")!.dependencies.sort(), + ).toEqual(["07", "08"]); + expect(project.tasks.find((t) => t.id === "12")!.dependencies).toEqual([ + "09", + ]); + expect( + project.tasks.find((t) => t.id === "13")!.dependencies.sort(), + ).toEqual(["10", "11", "12"]); + expect(project.tasks.find((t) => t.id === "14")!.dependencies).toEqual([ + "13", + ]); + } finally { + cleanup(); + } + }); + + it("parses all formats mixed into one complex file", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Config setup +- [ ] 02 — Database schema +- [ ] 03 — API routes +- [ ] 04 — Auth middleware +- [ ] 05 — Frontend shell +- [ ] 06 — User model +- [ ] 07 — Login page +- [ ] 08 — Dashboard +- [ ] 09 — Tests +- [ ] 10 — Deploy + +${FIO_FOOTER} +- 01 -> 02, 03, 04 (foundational layers) +- 05, 06 depend on 02, 03 +- 07 depends on 04, 06 +- 08 must be done before 09 +- 06, 07, 08 can be done in parallel (UI sprint) +- 09 -> 10 (quality gate before deploy) +`; + const { project, cleanup } = parse(content); + try { + // Arrow + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "01", + ]); + // depends on (plural) + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["02", "03"]); + expect( + project.tasks.find((t) => t.id === "06")!.dependencies.sort(), + ).toEqual(["02", "03"]); + // depends on (singular) + expect( + project.tasks.find((t) => t.id === "07")!.dependencies.sort(), + ).toEqual(["04", "06"]); + // must be done before + expect(project.tasks.find((t) => t.id === "09")!.dependencies).toEqual([ + "08", + ]); + // arrow again + expect(project.tasks.find((t) => t.id === "10")!.dependencies).toEqual([ + "09", + ]); + // parallel groups + expect(project.parallelGroups).toBeDefined(); + const uiSprint = project.parallelGroups!.find( + (g) => g.label === "UI sprint", + ); + expect(uiSprint).toBeDefined(); + expect(uiSprint!.taskIds.sort()).toEqual(["06", "07", "08"]); + } finally { + cleanup(); + } + }); +}); + +// ─── Plain Section Headings (without ##) ────────────────────────────────── + +describe("Plain section headings (no ##)", () => { + it("parses plain 'Tasks' and 'Dependencies' headings (the OAuth PRD format)", () => { + const content = `# iOS OAuth Sign-In\n\nObjective: Add Google and Apple OAuth sign-in options\n\nStatus legend: [ ] todo, [~] in-progress, [x] done\n\nTasks\n- [~] 01 — oauth-flow-research\n- [~] 02 — clerkapi-oauth-methods\n- [ ] 03 — authservice-oauth-methods\n- [ ] 04 — oauth-button-component\n- [ ] 05 — update-signin-view\n- [ ] 06 — update-signup-view\n- [ ] 07 — oauth-callback-handler\n- [ ] 08 — oauth-integration-tests\n\nDependencies\n- 02 depends on 01\n- 03 depends on 02\n- 04 depends on 01\n- 05 depends on 03, 04\n- 06 depends on 03, 04\n- 07 depends on 03\n- 08 depends on 05, 06, 07\n\nExit criteria\n- Users can sign in with Google account\n- Users can sign in with Apple account\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(8); + expect(project.tasks.find((t) => t.id === "01")!.dependencies).toEqual( + [], + ); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "01", + ]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect( + project.tasks.find((t) => t.id === "06")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect(project.tasks.find((t) => t.id === "07")!.dependencies).toEqual([ + "03", + ]); + expect( + project.tasks.find((t) => t.id === "08")!.dependencies.sort(), + ).toEqual(["05", "06", "07"]); + expect(project.exitCriteria).toBeDefined(); + expect(project.exitCriteria).toHaveLength(2); + expect(project.objective).toBe("iOS OAuth Sign-In"); + } finally { + cleanup(); + } + }); + + it("parses plain headings with arrow notation deps", () => { + const content = `# Design Token Integration\n\nTasks\n- [x] 01 — Migrate domain colors\n- [x] 02 — Replace corner radius\n- [x] 03 — Replace color leakage\n- [~] 04 — Replace raw spacing\n- [ ] 05 — Increase component adoption\n\nDependencies\n- 01 -> 02 (SemanticColors tokens must exist before views consume them)\n- 01 -> 03 (SemanticColors tokens must exist before views consume them)\n- 02 -> 04 (independent for clean git history)\n- 03 -> 04 (independent for clean git history)\n- 04 -> 05 (spacing consistency before component adoption)\n- 01 -> 05 (SemanticColors tokens before component-level adoption)\n\nExit criteria\n- Zero Color.systemGroupedBackground remain\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(5); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["02", "03"]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["01", "04"]); + } finally { + cleanup(); + } + }); + + it("ignores 'Status legend' line (has colon — not a section break)", () => { + const content = `# Test\n\nStatus legend: [ ] todo, [~] in-progress, [x] done\n\nTasks\n- [ ] 01 — First\n- [~] 02 — Second\n\nDependencies\n- 02 depends on 01\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(2); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("ignores 'Objective:' line (has colon — not a section break)", () => { + const content = `# Test\n\nObjective: Add Google and Apple OAuth\n\nTasks\n- [ ] 01 — Research\n- [ ] 02 — Implement\n\nDependencies\n- 02 depends on 01\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(2); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.objective).toBe("Test"); + } finally { + cleanup(); + } + }); +});