diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 08ed839..0000000 --- a/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -node_modules/**/*.js -dist -lib -tmp diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 5972865..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,41 +0,0 @@ -const prettier = require('./.prettierrc'); - -module.exports = { - extends: ['eslint-config-standard', 'plugin:prettier/recommended'], - rules: { - 'max-len': ['error', { code: prettier.printWidth, ignoreUrls: true }], // KEEP THIS IN SYNC - strict: 0, - 'arrow-parens': ['error', 'always'], - 'consistent-return': 0, - 'no-param-reassign': 0, - 'func-names': 0, - 'no-use-before-define': 0, - 'one-var': 0, - 'prefer-destructuring': 0, - 'no-template-curly-in-string': 0, - 'prefer-template': 0, - 'prefer-const': 0, - 'promise/avoid-new': 0, - 'promise/always-return': 0, - 'promise/no-nesting': 0, - 'promise/no-return-wrap': 0, - 'promise/no-callback-in-promise': 0, - 'promise/no-promise-in-callback': 0, - semi: ['error', 'always'], - // 'comma-dangle': ['error', 'always-multiline'], - }, - overrides: [ - { - files: ['t/**/*.js'], - plugins: ['mocha'], - env: { - mocha: true, - node: true, - }, - rules: { - 'mocha/valid-suite-description': 0, - 'mocha/valid-test-description': 0, - }, - }, - ], -}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 66e9e33..2eac9a4 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: monken +github: nmccready diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d1a7a95..35cda2d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,14 @@ updates: - package-ecosystem: "npm" directory: "/" # Location of package manifests schedule: - interval: "weekly" + interval: "daily" + cooldown: + default-days: 7 + groups: + all: + patterns: + - "*" + commit-message: # force conventional commits standard + prefix: fix + prefix-development: chore + include: scope diff --git a/.github/workflows/auto-merge-dependabot.yml_disable b/.github/workflows/auto-merge-dependabot.yml_disable new file mode 100644 index 0000000..030d6c6 --- /dev/null +++ b/.github/workflows/auto-merge-dependabot.yml_disable @@ -0,0 +1,13 @@ +name: Auto-merge Dependabot +on: pull_request + +jobs: + automerge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - uses: peter-evans/enable-pull-request-automerge@v8 + with: + token: ${{ secrets.GH_TOKEN || github.token }} + pull-request-number: ${{ github.event.pull_request.number }} + merge-method: squash diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 179ead3..3de1eb6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,28 +1,28 @@ -# TODO: ENABLE / UNCOMMENT WHEN NPM_TOKEN IS SET in secrets REPO -# name: publish +name: publish -# on: -# push: -# tags: -# - "v*" +on: + push: + tags: + - "v*" -# jobs: -# tests: -# uses: ./.github/workflows/tests.yml -# publish-npm: -# needs: [tests] -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# - name: Use Node.js ${{ matrix.node-version }} -# uses: actions/setup-node@v3 -# with: -# node-version: '20.x' -# registry-url: 'https://registry.npmjs.org' -# - name: Publish to npm -# run: | -# npm install -# npm publish --access public -# env: -# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -# #kick +jobs: + tests: + uses: ./.github/workflows/tests.yml + publish-npm: + needs: [tests] + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - name: Publish to npm + run: | # npm 11.15.1 for OIDC support + npm install -g npm@11 + npm ci + npm install + npm publish --access public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa6751f..2333871 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,38 +1,59 @@ # TODO: ENABLE / UNCOMMENT WHEN NPM_TOKEN IS SET in secrets REPO -# name: release - -# on: -# push: -# branches: ["master"] -# tags-ignore: ['**'] - -# jobs: -# tests: -# uses: ./.github/workflows/tests.yml -# tag-release: -# runs-on: ubuntu-latest -# needs: [tests] -# steps: -# - uses: actions/checkout@v4 -# with: # important, must be defined on checkout to kick publish (defining in setup/node doesn't work) -# token: ${{ secrets.GH_TOKEN }} -# - name: Use Node.js ${{ matrix.node-version }} -# uses: actions/setup-node@v3 -# with: -# node-version: '20.x' -# # cache: "npm" # needs lockfile if enabled - -# - name: tag release -# run: | -# # ignore if commit message is chore(release): ... -# if [[ $(git log -1 --pretty=%B) =~ ^chore\(release\):.* ]]; then -# echo "Commit message starts with 'chore(release):', skipping release" -# exit 0 -# fi -# git config --local user.email "creadbot@github.com" -# git config --local user.name "creadbot_github" -# set -v -# npm install -# npx commit-and-tag-version -# git push -# git push --tags +name: release + +on: + push: + branches: ["master"] + tags-ignore: ['**'] + +jobs: + tests: + uses: ./.github/workflows/tests.yml + tag-release: + runs-on: ubuntu-latest + needs: [tests] + steps: + - uses: actions/checkout@v4 + with: + # important, must be defined on checkout to kick publish + token: ${{ secrets.GH_TOKEN }} + # Full history needed for conventional-changelog to detect breaking changes + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '20.x' + + - name: tag release + run: | + # ignore if commit message is chore(release): ... + if [[ $(git log -1 --pretty=%B) =~ ^chore\(release\):.* ]]; then + echo "Commit message starts with 'chore(release):', skipping release" + exit 0 + fi + + git config --local user.email "creadbot@github.com" + git config --local user.name "creadbot_github" + + npm install + + # Check for breaking changes in commits since last tag + # Look for feat!:, fix!:, or BREAKING CHANGE in commit messages + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$LAST_TAG" ]; then + RANGE="$LAST_TAG..HEAD" + else + RANGE="HEAD" + fi + + # Check all commits (not just first-parent) for breaking changes + if git log $RANGE --format="%B" | grep -qE "(^[a-z]+!:|BREAKING CHANGE:)"; then + echo "Breaking change detected, forcing major version bump" + npx commit-and-tag-version --release-as major + else + npx commit-and-tag-version + fi + + git push + git push --tags diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60b63e9..f6e4f3d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,8 @@ name: tests on: workflow_call: + push: + branches: ["master"] pull_request: branches: ["master"] @@ -9,7 +11,7 @@ jobs: test: strategy: matrix: - node-version: ['18.x', '20.x', '22.x'] + node-version: ['20.x', '22.x', '24.x'] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index dbf2185..cf3ce67 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ tmp temp yarn.lock -package-lock.json +# package-lock.json # OIDC +dist/ diff --git a/.versionrc.json b/.versionrc.json new file mode 100644 index 0000000..f4ba5e9 --- /dev/null +++ b/.versionrc.json @@ -0,0 +1,13 @@ +{ + "types": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Bug Fixes"}, + {"type": "perf", "section": "Performance"}, + {"type": "refactor", "section": "Refactor"}, + {"type": "test", "section": "Tests"}, + {"type": "docs", "section": "Documentation"}, + {"type": "chore", "hidden": true} + ], + "commitUrlFormat": "https://github.com/brickhouse-tech/cfn-include/commit/{{hash}}", + "compareUrlFormat": "https://github.com/brickhouse-tech/cfn-include/compare/{{previousTag}}...{{currentTag}}" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b45278..ee87da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,229 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. -## [2.0.0](https://github.com/monken/cfn-include/compare/v1.4.1...v2.0.0) (2024-08-24) +## [4.1.2](https://github.com/brickhouse-tech/cfn-include/compare/v4.1.1...v4.1.2) (2026-02-14) + + +### Bug Fixes + +* **deps:** add globals and @eslint/js for eslint 10 ([3d6a39a](https://github.com/brickhouse-tech/cfn-include/commit/3d6a39a814827de1f751f9bcb3954d76b9052ed9)) +* **deps:** bump the all group across 1 directory with 7 updates ([ddd66b6](https://github.com/brickhouse-tech/cfn-include/commit/ddd66b676be60bf5e53a4431e03855e462351e26)) + +## [4.1.1](https://github.com/brickhouse-tech/cfn-include/compare/v4.1.0...v4.1.1) (2026-02-14) + +## [4.1.0](https://github.com/brickhouse-tech/cfn-include/compare/v4.0.1...v4.1.0) (2026-02-14) + + +### Features + +* migrate tests from Mocha to Vitest + TypeScript (Phase 3b) ([47ccd3d](https://github.com/brickhouse-tech/cfn-include/commit/47ccd3d385a24e7689e5971bcb711950e39bc645)) + +## [4.0.1](https://github.com/brickhouse-tech/cfn-include/compare/v4.0.0...v4.0.1) (2026-02-12) + + +### Bug Fixes + +* **deps:** bump the all group across 1 directory with 7 updates ([56989d3](https://github.com/brickhouse-tech/cfn-include/commit/56989d3f2de9c50bc5968b4cafcb059b887b96f4)) + +## [4.0.0](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.24...v4.0.0) (2026-02-08) +### ⚠ BREAKING CHANGES + +* **release:** Package is now ESM-only. CommonJS require() no longer works. + +- Fix version bump (should have been 3.0.0 due to ESM breaking change) +- Update CHANGELOG.md with Phase 1-3 changes +- Add .versionrc.json for commit-and-tag-version config + +* **release:** 3.0.0 ([3d0bf89](https://github.com/brickhouse-tech/cfn-include/commit/3d0bf89a39d73d0f9645f96b6d56bd3ec0dce6a0)) + + +### Bug Fixes + +* **ci:** detect breaking changes in merge commits ([09f848c](https://github.com/brickhouse-tech/cfn-include/commit/09f848c434d9b287283fd9c48ed8e48d19a3cbd1)) + +## [3.0.0](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.24...v3.0.0) (2026-02-08) + +### ⚠ BREAKING CHANGES + +* **ESM-only:** Package is now ESM-only. CommonJS `require()` no longer works. Use dynamic `import()` or migrate to ESM. + +### Features + +* **Phase 3a:** add TypeScript source with build pipeline ([4b576b7](https://github.com/brickhouse-tech/cfn-include/commit/4b576b7)) + - Create src/ directory structure (lib/, types/, lib/include/) + - Add comprehensive type definitions in src/types/ + - Convert 13 lib files to TypeScript + - Add main src/index.ts with all Fn:: handlers + - Configure TypeScript (strict mode, ES2022, NodeNext) + +## [2.1.24](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.23...v2.1.24) (2026-02-08) + +*Released in error - see 3.0.0* + +## [2.1.23](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.22...v2.1.23) (2026-02-08) + +*No notable changes* + +## [2.1.22](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.21...v2.1.22) (2026-02-08) + +### ⚠ BREAKING CHANGES + +* **ESM:** Package converted to ES Modules. CommonJS `require()` no longer works. + +### Features + +* convert to ES Modules (ESM) ([0ceb431](https://github.com/brickhouse-tech/cfn-include/commit/0ceb431)) + +### Bug Fixes + +* convert benchmark runner to ESM ([ba70ddb](https://github.com/brickhouse-tech/cfn-include/commit/ba70ddb)) +* rename config files to .cjs for ESM compatibility ([d77608a](https://github.com/brickhouse-tech/cfn-include/commit/d77608a)) + +## [2.1.21](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.20...v2.1.21) (2026-02-08) + +### Refactor + +* remove bluebird and path-parse dependencies ([9ae7a59](https://github.com/brickhouse-tech/cfn-include/commit/9ae7a59)) + +## [2.1.20](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.19...v2.1.20) (2026-02-08) + +### Performance + +* **Phase 1 optimizations:** + - async glob for Fn::Include ([6409511](https://github.com/brickhouse-tech/cfn-include/commit/6409511)) + - file content cache for Fn::Include ([0724a23](https://github.com/brickhouse-tech/cfn-include/commit/0724a23)) + - replace simple lodash calls with native alternatives ([0ad6df6](https://github.com/brickhouse-tech/cfn-include/commit/0ad6df6)) + - regex pre-compilation cache in replaceEnv ([34f2e93](https://github.com/brickhouse-tech/cfn-include/commit/34f2e93)) + - Object.create() for O(1) scope creation in Fn::Map ([22aae96](https://github.com/brickhouse-tech/cfn-include/commit/22aae96)) + +### Tests + +* add regression test suite for Phase 1 optimizations ([def9fd2](https://github.com/brickhouse-tech/cfn-include/commit/def9fd2)) + +## [2.1.19](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.18...v2.1.19) (2026-02-08) + +### Bug Fixes + +* lint errors in benchmark-runner.js (trailing commas) ([8257735](https://github.com/brickhouse-tech/cfn-include/commit/8257735)) + +### Documentation + +* clarify scope vs body cloning optimization strategy ([4c3ddc1](https://github.com/brickhouse-tech/cfn-include/commit/4c3ddc1)) +* add Phase 3 TypeScript Analysis ([3758d4d](https://github.com/brickhouse-tech/cfn-include/commit/3758d4d)) +* add Phase 4 CDK Integration Analysis ([38a7090](https://github.com/brickhouse-tech/cfn-include/commit/38a7090)) + +### Features + +* **benchmarks:** add Phase 1 performance analysis and benchmark suite ([7bb7670](https://github.com/brickhouse-tech/cfn-include/commit/7bb7670)) + +## [2.1.18](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.17...v2.1.18) (2026-02-04) + +*No notable changes* + +## [2.1.17](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.16...v2.1.17) (2026-02-04) + +### Bug Fixes + +* **deps:** bump @isaacs/brace-expansion from 5.0.0 to 5.0.1 ([92f242e](https://github.com/brickhouse-tech/cfn-include/commit/92f242e)) +* **deps:** bump the all group with 2 updates ([c10adc7](https://github.com/brickhouse-tech/cfn-include/commit/c10adc7)) + +## [2.1.16](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.15...v2.1.16) (2026-01-30) + +### Security + +* CVE fast-xml-parse 5.3.4 override ([ca5c7cc](https://github.com/brickhouse-tech/cfn-include/commit/ca5c7cc)) + +## [2.1.15](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.14...v2.1.15) (2026-01-30) + +### Bug Fixes + +* **deps:** bump the all group with 3 updates ([f0c5bd2](https://github.com/brickhouse-tech/cfn-include/commit/f0c5bd2)) + +## [2.1.14](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.13...v2.1.14) (2026-01-27) + +### Bug Fixes + +* **deps:** bump @znemz/cft-utils from 0.1.30 to 0.1.31 in the all group ([06669aa](https://github.com/brickhouse-tech/cfn-include/commit/06669aa)) + +## [2.1.13](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.12...v2.1.13) (2026-01-22) + +### Bug Fixes + +* Fn::RefNow bug fixes ([f42119e](https://github.com/brickhouse-tech/cfn-include/commit/f42119e)) + +## [2.1.12](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.11...v2.1.12) (2026-01-22) + +### Features + +* RefNow LogicalId support ([2a01d82](https://github.com/brickhouse-tech/cfn-include/commit/2a01d82)) + +## [2.1.11](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.10...v2.1.11) (2026-01-22) + +### Features + +* refNowIgnoreMissing and refNowIgnores for cli for passthrough ([cfd4740](https://github.com/brickhouse-tech/cfn-include/commit/cfd4740)) + +## [2.1.10](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.9...v2.1.10) (2026-01-22) + +### Bug Fixes + +* **deps:** bump lodash from 4.17.21 to 4.17.23 ([f45ef98](https://github.com/brickhouse-tech/cfn-include/commit/f45ef98)) + +## [2.1.9](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.8...v2.1.9) (2026-01-17) + +### Features + +* Fn::RefNow ([81e6867](https://github.com/brickhouse-tech/cfn-include/commit/81e6867)) +* Fn::SubNow ([45a1cbc](https://github.com/brickhouse-tech/cfn-include/commit/45a1cbc)) + +## [2.1.8](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.7...v2.1.8) (2025-12-24) + +### Chore + +* npm publish OIDC ([bef7a21](https://github.com/brickhouse-tech/cfn-include/commit/bef7a21)) + +## [2.1.7](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.6...v2.1.7) (2025-12-12) + +### Bug Fixes + +* **deps:** bump the all group across 1 directory with 2 updates ([4d2266e](https://github.com/brickhouse-tech/cfn-include/commit/4d2266e)) + +## [2.1.6](https://github.com/brickhouse-tech/cfn-include/compare/v2.1.5...v2.1.6) (2025-11-21) + +### Bug Fixes + +* safeDump to dump ([98393d1](https://github.com/nmccready/cfn-include/commit/98393d1902a7ee681bc5bd214a244a65c0222bec)) + +## 2.1.5 (2025-11-18) + +## 2.1.4 (2025-11-06) + +## 2.1.3 (2025-09-01) + +## 2.1.2 (2025-05-12) + +## 2.1.1 (2025-02-26) + +## [2.1.0](https://github.com/brickhouse-tech/cfn-include/compare/v2.0.2...v2.1.0) (2025-01-21) + +### Features + +* env file support CFN_INCLUDE_(DO_ENV|DO_EVAL) ([8e6208d](https://github.com/nmccready/cfn-include/commit/8e6208d4762710268da2a2e011576f341a3986d3)) + +## [2.0.2](https://github.com/nmccready/cfn-include/compare/v2.0.1...v2.0.2) (2025-01-21) + +## [2.0.1](https://github.com/nmccready/cfn-include/compare/v2.0.0...v2.0.1) (2024-11-14) + +### Bug Fixes + +* dependency bump CVE serve ([eed7ac5](https://github.com/nmccready/cfn-include/commit/eed7ac5de3dbb5a0607d8966d1c220857b8cc636)) +* **handleIncludeBody:** loopTemplate pass on option doEval ([95dd1a0](https://github.com/nmccready/cfn-include/commit/95dd1a0059fbf4ac37e445cd407c5baec2c3792a)) +* scoped to @znemz/cfn-include to publish 2.0.0 ([492e479](https://github.com/nmccready/cfn-include/commit/492e479a8fa8c1e15a33ce3a7962a7cca5affb94)) + +## [2.0.0](https://github.com/monken/cfn-include/compare/v1.4.1...v2.0.0) (2024-08-24) + ### ⚠ BREAKING CHANGES * capital one features and more Fn::* @@ -15,7 +235,6 @@ All notable changes to this project will be documented in this file. See [commit * cli added --context to allow stdin to work with includes ([ee33ba9](https://github.com/monken/cfn-include/commit/ee33ba95bee24ce04b262001f05951947621b27d)) * cli added --context to allow stdin to work with includes ([7f6986f](https://github.com/monken/cfn-include/commit/7f6986fb34dad85c700ecccd70ec2f49895b2523)) - ### Bug Fixes * cve globby issue resolved via glob ([7e27d12](https://github.com/monken/cfn-include/commit/7e27d1272996ead317ab6448e672f4787a3d882b)) diff --git a/README.md b/README.md index 2f34673..fe4139f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ For example, [`Fn::Include`](#fninclude) provides a convenient way to include fi - [Fn::Eval](#fneval) - [Fn::IfEval](#fnifeval) - [Fn::JoinNow](#fnjoinnow) + - [Fn::SubNow](#fsubnow) + - [Fn::RefNow](#frefnow) - [Fn::ApplyTags](#fnapplytags) - [Fn::Outputs](#fnoutputs) - [More Examples](#more-examples) @@ -58,7 +60,7 @@ Tag-based syntax is available in YAML templates. For example,`Fn::Include` becom You can either install `cfn-include` or use a web service to compile templates. ``` -npm install --global cfn-include +npm install --global @znemz/cfn-include ``` The web service can be called with your favorite CLI tool such as `curl`. @@ -87,11 +89,13 @@ Options: * `--prefix` prefix for templates uploaded to the bucket ['cfn-include'] * `--version` print version and exit * `--context` template full path. only utilized for stdin when the template is piped to this script - example: `cat examples/base.template | ./bin/cli.js --context examples/base.template` + example: `cat examples/base.template | cfn-include --context examples/base.template` * `--enable` different options / toggles: ['env','eval'] [string] [choices: 'env','eval','env.eval' etc...] * `env` pre-process env vars and inject into templates as they are processed looks for $KEY or ${KEY} matches * `-i, --inject` JSON string payload to use for template injection. (Takes precedence over process.env (if enabled) injection and will be merged on top of process.env) -* `--doLog` console log out include options in recurse step. +* `--doLog` console log out include options in recurse step. Shows caller parameter to aid debugging nested function calls. +* `--ref-now-ignore-missing` do not fail if `Fn::RefNow` reference cannot be resolved; instead return in standard CloudFormation `Ref` syntax +* `--ref-now-ignores ` comma-separated list of reference names to ignore if not found (e.g., `OptionalRef1,OptionalRef2`) `cfn-include` also accepts a template passed from stdin ``` @@ -123,7 +127,7 @@ This is what the `userdata.sh` looks like: ```bash cfn-include synopsis.json > output.template # you can also compile remote files -cfn-include https://raw.githubusercontent.com/monken/cfn-include/master/examples/synopsis.json > output.template +cfn-include https://raw.githubusercontent.com/nmccready/cfn-include/master/examples/synopsis.json > output.template ``` The output will be something like this: @@ -816,7 +820,7 @@ Results in: ## Fn::Sort -`$ ./bin/cli.js [examples/sort.yaml](examples/sort.yaml)` +`$ cfn-include [examples/sort.yaml](examples/sort.yaml)` ```json [ @@ -835,7 +839,7 @@ Results in: ## Fn::SortedUniq -`$ ./bin/cli.js [examples/sortedUniq.yaml](examples/sortedUniq.yaml)` +`$ cfn-include [examples/sortedUniq.yaml](examples/sortedUniq.yaml)` ```json [ @@ -852,7 +856,7 @@ Results in: ## Fn::SortBy -`$ ./bin/cli.js [examples/sortBy.yaml](examples/sortBy.yaml)` +`$ cfn-include [examples/sortBy.yaml](examples/sortBy.yaml)` ```json [ @@ -895,7 +899,7 @@ Results in: See: [examples/sortObject.yaml](examples/sortObject.yaml) -`$ ./bin/cli.js examples/sortObject.yaml` +`$ cfn-include examples/sortObject.yaml` ```json { @@ -1071,6 +1075,211 @@ Fn::JoinNow: arn:aws:s3:::c1-acme-iam-cache-engine-${AWS::AccountId}-us-east-1$CFT_STACK_SUFFIX ``` +## Fn::SubNow + +`Fn::SubNow` performs immediate string substitution similar to AWS CloudFormation's `Fn::Sub`, but evaluates during template preprocessing rather than at stack creation time. It supports variable substitution using `${VariableName}` syntax and AWS pseudo-parameters. + +The function supports two input formats: + +**String format:** +```yaml +Fn::SubNow: "arn:aws:s3:::bucket-${BucketSuffix}" +``` + +**Array format with variables:** +```yaml +Fn::SubNow: + - "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroupName}" + - LogGroupName: /aws/lambda/my-function +``` + +**Supported AWS pseudo-parameters:** +- `${AWS::AccountId}` - AWS Account ID +- `${AWS::Region}` - AWS Region +- `${AWS::StackName}` - Stack name +- `${AWS::StackId}` - Stack ID +- `${AWS::Partition}` - AWS Partition (e.g., 'aws') +- `${AWS::URLSuffix}` - URL suffix (e.g., 'amazonaws.com') + +Variables can be provided via: +1. The `inject` option passed to `cfn-include` +2. Explicit variables in the array format (takes precedence over `inject`) +3. Environment variables when using the `doEnv` option + +**Example with environment variables:** +```yaml +BucketName: !SubNow "my-bucket-${Environment}-${AWS::Region}" +``` + +With `CFN_INCLUDE_DO_ENV=true` and environment variable `ENVIRONMENT=prod`, this resolves to: +```yaml +BucketName: "my-bucket-prod-us-east-1" +``` + +## Fn::RefNow + +`Fn::RefNow` resolves a reference immediately during template preprocessing, similar to AWS CloudFormation's `Fn::Ref` but evaluated at processing time rather than stack creation time. It resolves references to parameters, variables, and AWS pseudo-parameters. + +**Basic syntax:** +```yaml +BucketRef: + Fn::RefNow: BucketName +``` + +or using YAML tag syntax: +```yaml +BucketRef: !RefNow BucketName +``` + +**Supported AWS pseudo-parameters:** +- `AWS::AccountId` - AWS Account ID (environment: `AWS_ACCOUNT_ID` or `AWS_ACCOUNT_NUM`, fallback: `${AWS::AccountId}`) +- `AWS::Region` - AWS Region (environment: `AWS_REGION`, fallback: `${AWS::Region}`) +- `AWS::StackName` - Stack name (environment: `AWS_STACK_NAME`, fallback: `${AWS::StackName}`) +- `AWS::StackId` - Stack ID (environment: `AWS_STACK_ID`, fallback: `${AWS::StackId}`) +- `AWS::Partition` - AWS Partition (environment: `AWS_PARTITION`, default: `'aws'`) +- `AWS::URLSuffix` - URL suffix (environment: `AWS_URL_SUFFIX`, default: `'amazonaws.com'`) +- `AWS::NotificationARNs` - SNS topic ARNs for notifications (environment: `AWS_NOTIFICATION_ARNS`, fallback: `${AWS::NotificationARNs}`) + +**Reference resolution priority:** +1. AWS pseudo-parameters (with environment variable fallbacks) +2. Variables from the `inject` option +3. Variables from the current scope (useful with `Fn::Map`) + +**Reference indirection:** +If a resolved reference is a string, it will be treated as a reference name and resolved again. This enables reference chaining, useful when using `Fn::RefNow` with `Fn::Map`: + +```yaml +Fn::Map: + - [BucketVar1, BucketVar2] + - BucketName: + Fn::RefNow: _ +``` + +With `inject: { BucketVar1: "my-bucket-1", BucketVar2: "my-bucket-2" }`, this via + +`$ Bucket1=my-bucket-1 BucketVar2=my-bucket-2 cnf-include examples/refNow.yml --enable env,eval` via exports / env + +or + +`$ cnf-include examples/refNow.yml --enable env,eval --inject '{"BucketVar1":"my-bucket-1","BucketVar2":"my-bucket-2"}'` via inject + +resolves to: +```yaml +BucketVar1: my-bucket-1 +BucketVar2: my-bucket-2 +``` + +The `_` placeholder resolves to `"BucketVar1"` or `"BucketVar2"`, which are then resolved again to their actual values. + +**Example with injected variables:** +```yaml +Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::RefNow: BucketName +``` + +With `inject: { BucketName: "my-app-bucket" }`, this resolves to: +```yaml +BucketName: "my-app-bucket" +``` + +**Example with AWS pseudo-parameters:** +```yaml +LogGroup: + Fn::RefNow: AWS::StackName +``` + +With `AWS_STACK_NAME=my-stack`, this resolves to: +```yaml +LogGroup: "my-stack" +``` + +**Unresolved AWS pseudo-parameters:** +If an AWS pseudo-parameter is not set via environment variables, it falls back to a placeholder string (e.g., `${AWS::AccountId}`). Only `AWS::Partition` and `AWS::URLSuffix` have hardcoded defaults since they are rarely environment-specific. + +**Error handling:** +If a reference cannot be resolved, `Fn::RefNow` will throw an error. Ensure all referenced parameters and variables are available via inject, scope, or environment variables. + +**Resolving LogicalResourceIds:** + +`Fn::RefNow` can also resolve references to CloudFormation Resource LogicalResourceIds, enabling you to construct ARNs or other resource-specific values during template preprocessing. When a reference matches a LogicalResourceId in the Resources section, `Fn::RefNow` will automatically generate the appropriate ARN based on the resource type and properties. + +**Supported Resource Types for ARN/Name Resolution:** + +- `AWS::IAM::ManagedPolicy` - Returns policy ARN (supports Path) +- `AWS::IAM::Role` - Returns role ARN (supports Path) +- `AWS::IAM::InstanceProfile` - Returns instance profile ARN (supports Path) +- `AWS::S3::Bucket` - Returns bucket ARN +- `AWS::Lambda::Function` - Returns function ARN +- `AWS::SQS::Queue` - Returns queue ARN +- `AWS::SNS::Topic` - Returns topic ARN +- `AWS::DynamoDB::Table` - Returns table ARN +- `AWS::RDS::DBInstance` - Returns DB instance ARN +- `AWS::SecretsManager::Secret` - Returns secret ARN +- `AWS::KMS::Key` - Returns key ARN + +Example with AWS::IAM::ManagedPolicy: + +```yaml +Resources: + ObjPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + ManagedPolicyName: teststack-CreateTestDBPolicy-16M23YE3CS700 + Path: /CRAP/ + + IAMRole: + Type: AWS::IAM::Role + Properties: + ManagedPolicyArns: + - Fn::RefNow: ObjPolicy # Resolves to: arn:aws:iam::${AWS_ACCOUNT_ID}:policy/CRAP/teststack-CreateTestDBPolicy-16M23YE3CS700 +``` + +**Returning Resource Names Instead of ARNs:** + +By default, `Fn::RefNow` returns the ARN for supported resource types. However, if the key name ends with `Name` (e.g., `RoleName`, `BucketName`, `FunctionName`), it automatically returns the resource name/identifier instead: + +```yaml +Resources: + MyRole: + Type: AWS::IAM::Role + Properties: + RoleName: MyExecutionRole + + RoleArn: + Fn::RefNow: MyRole # Returns: arn:aws:iam::${AWS_ACCOUNT_ID}:role/MyExecutionRole + + RoleName: + Fn::RefNow: MyRole # Returns: MyExecutionRole (because key ends with "Name") +``` + +This intuitive approach makes templates more readable and follows natural CloudFormation naming conventions. + +**CLI Options for Fn::RefNow:** + +The `cfn-include` command provides CLI options to control how unresolved references are handled: + +- `--ref-now-ignore-missing`: Do not fail if a `Fn::RefNow` reference cannot be resolved. Instead, the reference will be returned in AWS CloudFormation's standard `Ref` syntax (e.g., `{ Ref: 'UnresolvedRef' }`), allowing CloudFormation to resolve it at stack creation time. +- `--ref-now-ignores `: Comma-separated list of reference names to ignore if not found. These references will be returned in `Ref` syntax instead of throwing an error. + +**Example usage:** + +```bash +# Ignore all unresolved references +cfn-include template.yaml --ref-now-ignore-missing + +# Ignore specific reference names +cfn-include template.yaml --ref-now-ignores "OptionalRef1,OptionalRef2" + +# Combine both options +cfn-include template.yaml --ref-now-ignore-missing --ref-now-ignores "SpecificRef" +``` + +This is useful for templates that reference CloudFormation parameters or other resources that may not be available at template processing time but will be available at stack creation time. + ## Fn::ApplyTags See [ApplyTags test file](t/tests/applyTags.yml). @@ -1133,7 +1342,7 @@ Outputs: ## More Examples -See [/examples](https://github.com/monken/cfn-include/tree/master/examples) for templates that call an API Gateway endpoint to collect AMI IDs for all regions. There is also a good amount of [tests](https://github.com/monken/cfn-include/tree/master/t) that might be helpful. +See [/examples](https://github.com/nmccready/cfn-include/tree/master/examples) for templates that call an API Gateway endpoint to collect AMI IDs for all regions. There is also a good amount of [tests](https://github.com/nmccready/cfn-include/tree/master/t) that might be helpful. A common pattern is to process a template, validate it against the AWS [validate-template](https://docs.aws.amazon.com/cli/latest/reference/cloudformation/validate-template.html) API, minimize it and upload the result to S3. You can do this with a single line of code: @@ -1163,8 +1372,124 @@ Options are query parameters. - `validate=false` do not validate template [true] +## Developer Documentation + +### Debug Logging with doLog + +The `cfn-include` preprocessor supports comprehensive debug logging for tracing template processing. When enabled, the `doLog` option logs all arguments at each recursion level in the template processing pipeline. + +#### Enabling doLog + +**CLI:** +```bash +cfn-include template.yaml --doLog +``` + +**Programmatic:** +```javascript +include({ + template: myTemplate, + url: 'file:///path/to/template.yaml', + doLog: true +}) +``` + +#### Understanding Caller Parameter + +The `recurse` function now includes a `caller` parameter to help identify which function triggered each recursion step. This is invaluable for debugging complex templates with nested function calls. The caller parameter provides a trace path like: + +- `recurse:isArray` - Processing an array +- `Fn::Map` - Inside an `Fn::Map` function +- `Fn::Include` - Inside an `Fn::Include` function +- `recurse:isPlainObject:end` - Final plain object processing +- `handleIncludeBody:json` - JSON body being processed + +When `doLog` is enabled, the console output will show the caller for each recursion: +```javascript +{ + base: {...}, + scope: {...}, + cft: {...}, + rootTemplate: {...}, + caller: "Fn::Map", + doEnv: false, + doEval: false, + ... +} +``` + +This makes it easy to trace execution flow through nested `Fn::Include`, `Fn::Map`, `Fn::RefNow`, and other functions. + +#### Example Debug Output + +```bash +$ cfn-include examples/base.template --doLog | head -50 +{ + base: { + protocol: 'file', + host: '/Users/SOME_USER/code', + path: '/examples/base.template' + }, + scope: {}, + cft: { AWSTemplateFormatVersion: '2010-09-09', ... }, + rootTemplate: { AWSTemplateFormatVersion: '2010-09-09', ... }, + caller: 'recurse:isPlainObject:end', + doEnv: false, + doEval: false, + inject: undefined, + doLog: true +} +``` + +### Fn::RefNow Improvements + +#### CLI Options for Reference Resolution + +Two new CLI options control how unresolved `Fn::RefNow` references are handled: + +- `--ref-now-ignore-missing`: Do not fail if a reference cannot be resolved. Instead, return the reference in CloudFormation's standard `Ref` syntax, allowing CloudFormation to resolve it at stack creation time. + +- `--ref-now-ignores `: Comma-separated list of specific reference names to ignore if not found. Useful for optional references. + +**Example usage:** +```bash +# Ignore all unresolved references +cfn-include template.yaml --ref-now-ignore-missing + +# Ignore specific references +cfn-include template.yaml --ref-now-ignores "OptionalParam,CustomRef" + +# Combine both +cfn-include template.yaml --ref-now-ignore-missing --ref-now-ignores "SpecificRef" +``` + +#### rootTemplate Parameter + +The `recurse` function now receives the complete `rootTemplate` for all recursion calls. This enables `Fn::RefNow` to resolve references to CloudFormation resources defined anywhere in the template, even when processing deeply nested includes or function results. + +### Fn::SubNow and Fn::JoinNow + +New intrinsic functions for immediate string substitution and joining: + +- `Fn::SubNow` - Performs immediate string substitution similar to `Fn::Sub`, but evaluates at template processing time +- `Fn::JoinNow` - Joins array elements into a string at template processing time + +See the main documentation sections above for detailed usage. + +### Template Processing Pipeline + +The template processing follows this call chain for better debugging: + +1. Entry point calls `recurse()` with `caller: undefined` +2. Array elements call `recurse()` with `caller: 'recurse:isArray'` +3. Each `Fn::*` function calls `recurse()` with `caller: 'Fn::FunctionName'` +4. Final plain object recursion uses `caller: 'recurse:isPlainObject:end'` +5. Include body processing uses `caller: 'handleIncludeBody:json'` + +When combined with `--doLog`, this provides complete visibility into how `cfn-include` processes your templates. + To compile the synopsis run the following command. ``` -curl -Ssf -XPOST https://api.netcubed.de/latest/template -d '{"Fn::Include":"https://raw.githubusercontent.com/monken/cfn-include/master/examples/synopsis.json"}' > output.template +curl -Ssf -XPOST https://api.netcubed.de/latest/template -d '{"Fn::Include":"https://raw.githubusercontent.com/nmccready/cfn-include/master/examples/synopsis.json"}' > output.template ``` diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..3128aee --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,49 @@ +# cfn-include Benchmark Suite + +Performance benchmarks for measuring template compilation times and memory usage. + +## Running Benchmarks + +```bash +# Basic run +node benchmarks/benchmark-runner.js + +# With GC exposed for accurate memory measurement +node --expose-gc benchmarks/benchmark-runner.js +``` + +## What's Measured + +1. **Simple Template** - Baseline with no custom Fn:: functions +2. **Fn::Map (10/100/1000 items)** - Tests scaling behavior +3. **Nested Fn::Map (3-deep)** - Tests recursion overhead +4. **Fn::Include chains (3/10-deep)** - Tests include resolution +5. **Glob operations (10/100 files)** - Tests file discovery +6. **Complex template** - Mixed real-world scenario + +## Output + +Results are printed to console and saved to `results.json`. + +## Fixtures + +The benchmark runner auto-generates fixtures in `fixtures/` directory: +- Simple templates +- Map templates with varying sizes +- Nested map templates +- Include chain templates +- Glob test files + +## Adding New Benchmarks + +Edit `benchmark-runner.js` and add a new `runBenchmark()` call: + +```javascript +results.push(await runBenchmark('My New Benchmark', async () => { + await include({ + template: myTemplate, + url: 'file:///path/to/template.json', + }); +})); +printResult(results[results.length - 1]); +``` diff --git a/benchmarks/baseline-before-quickwins.json b/benchmarks/baseline-before-quickwins.json new file mode 100644 index 0000000..e121b1d --- /dev/null +++ b/benchmarks/baseline-before-quickwins.json @@ -0,0 +1,78 @@ +{ + "timestamp": "2026-02-08T18:11:56.440Z", + "nodeVersion": "v22.22.0", + "platform": "darwin", + "arch": "arm64", + "results": [ + { + "name": "Simple Template (baseline)", + "avgMs": 0.3760584000000051, + "minMs": 0.16200000000000614, + "maxMs": 1.0092090000000127, + "memoryDeltaBytes": 303952 + }, + { + "name": "Fn::Map (10 items)", + "avgMs": 1.6977333999999928, + "minMs": 1.3721670000000046, + "maxMs": 2.4862079999999764, + "memoryDeltaBytes": -1763328 + }, + { + "name": "Fn::Map (100 items)", + "avgMs": 5.699275000000005, + "minMs": 3.221917000000019, + "maxMs": 11.950000000000017, + "memoryDeltaBytes": -5786384 + }, + { + "name": "Fn::Map (1000 items)", + "avgMs": 28.46569999999999, + "minMs": 27.931165999999962, + "maxMs": 29.007542, + "memoryDeltaBytes": 5221744 + }, + { + "name": "Nested Fn::Map (3-deep, 3x3x3=27 items)", + "avgMs": 3.7033664000000046, + "minMs": 3.3335409999999683, + "maxMs": 4.05479200000002, + "memoryDeltaBytes": 4920744 + }, + { + "name": "Fn::Include chain (3-deep)", + "avgMs": 0.626566600000001, + "minMs": 0.45583299999998417, + "maxMs": 0.9602500000000305, + "memoryDeltaBytes": -12612048 + }, + { + "name": "Fn::Include chain (10-deep)", + "avgMs": 1.9377081999999972, + "minMs": 1.8670000000000186, + "maxMs": 1.9903339999999616, + "memoryDeltaBytes": 2607624 + }, + { + "name": "Glob (10 files)", + "avgMs": 0.054025000000001454, + "minMs": 0.04825000000005275, + "maxMs": 0.06941699999998718, + "memoryDeltaBytes": 45664 + }, + { + "name": "Glob (100 files)", + "avgMs": 0.05366680000001907, + "minMs": 0.047584000000028936, + "maxMs": 0.06725000000000136, + "memoryDeltaBytes": 45736 + }, + { + "name": "Complex template (mixed features)", + "avgMs": 0.9749168000000055, + "minMs": 0.8508340000000203, + "maxMs": 1.1594589999999698, + "memoryDeltaBytes": 3533864 + } + ] +} \ No newline at end of file diff --git a/benchmarks/benchmark-runner.js b/benchmarks/benchmark-runner.js new file mode 100644 index 0000000..b20ab05 --- /dev/null +++ b/benchmarks/benchmark-runner.js @@ -0,0 +1,546 @@ +/** + * cfn-include Performance Benchmark Suite + * + * Measures: + * - Template compilation times for various complexity levels + * - Memory usage during compilation + * - Nested template performance (1-deep, 3-deep, 10-deep) + * - Fn::Map with varying array sizes (10, 100, 1000 items) + * - Glob operations with varying file counts + */ + +import { performance } from 'node:perf_hooks'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import include from '../dist/index.js'; +import * as yaml from '../dist/lib/yaml.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ANSI colors for output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + red: '\x1b[31m', +}; + +/** + * Format memory usage in human-readable format + */ +function formatMemory(bytes) { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(2)} MB`; +} + +/** + * Format duration in human-readable format + */ +function formatDuration(ms) { + if (ms < 1) return `${(ms * 1000).toFixed(2)}μs`; + if (ms < 1000) return `${ms.toFixed(2)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +/** + * Get memory usage + */ +function getMemoryUsage() { + const usage = process.memoryUsage(); + return { + heapUsed: usage.heapUsed, + heapTotal: usage.heapTotal, + external: usage.external, + rss: usage.rss, + }; +} + +/** + * Run a benchmark with memory tracking + */ +async function runBenchmark(name, fn, iterations = 5) { + const results = []; + + // Warm up + await fn(); + + // Force GC if available + if (global.gc) global.gc(); + + const startMemory = getMemoryUsage(); + + for (let i = 0; i < iterations; i++) { + if (global.gc) global.gc(); + + const start = performance.now(); + await fn(); + const end = performance.now(); + + results.push(end - start); + } + + const endMemory = getMemoryUsage(); + + const avg = results.reduce((a, b) => a + b, 0) / results.length; + const min = Math.min(...results); + const max = Math.max(...results); + const memoryDelta = endMemory.heapUsed - startMemory.heapUsed; + + return { + name, + iterations, + avg, + min, + max, + memoryDelta, + startMemory: startMemory.heapUsed, + endMemory: endMemory.heapUsed, + }; +} + +/** + * Print benchmark result + */ +function printResult(result) { + console.log(`${colors.cyan}${result.name}${colors.reset}`); + console.log(` Iterations: ${result.iterations}`); + console.log(` Average: ${colors.green}${formatDuration(result.avg)}${colors.reset}`); + console.log(` Min: ${formatDuration(result.min)}`); + console.log(` Max: ${formatDuration(result.max)}`); + console.log(` Memory Delta: ${formatMemory(result.memoryDelta)}`); + console.log(); +} + +/** + * Generate fixture templates + */ +function generateFixtures() { + const fixturesDir = path.join(__dirname, 'fixtures'); + + // Simple template + const simple = { + AWSTemplateFormatVersion: '2010-09-09', + Description: 'Simple benchmark template', + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: 'my-simple-bucket', + }, + }, + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'simple.json'), JSON.stringify(simple, null, 2)); + + // Template with Fn::Map (10 items) + const map10 = { + Resources: { + 'Fn::Merge': [ + { + 'Fn::Map': [ + Array.from({ length: 10 }, (_, i) => `item${i}`), + '$', + { + 'Bucket${$}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { 'Fn::Sub': 'bucket-${$}' }, + }, + }, + }, + ], + }, + ], + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'map-10.json'), JSON.stringify(map10, null, 2)); + + // Template with Fn::Map (100 items) + const map100 = { + Resources: { + 'Fn::Merge': [ + { + 'Fn::Map': [ + Array.from({ length: 100 }, (_, i) => `item${i}`), + '$', + { + 'Bucket${$}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { 'Fn::Sub': 'bucket-${$}' }, + }, + }, + }, + ], + }, + ], + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'map-100.json'), JSON.stringify(map100, null, 2)); + + // Template with Fn::Map (1000 items) + const map1000 = { + Resources: { + 'Fn::Merge': [ + { + 'Fn::Map': [ + Array.from({ length: 1000 }, (_, i) => `item${i}`), + '$', + { + 'Bucket${$}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { 'Fn::Sub': 'bucket-${$}' }, + }, + }, + }, + ], + }, + ], + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'map-1000.json'), JSON.stringify(map1000, null, 2)); + + // Nested Map template (3-deep) + const nestedMap3 = { + Resources: { + 'Fn::Merge': [ + { + 'Fn::Map': [ + ['a', 'b', 'c'], + 'outer', + { + 'Fn::Merge': [ + { + 'Fn::Map': [ + ['x', 'y', 'z'], + 'middle', + { + 'Fn::Merge': [ + { + 'Fn::Map': [ + [1, 2, 3], + 'inner', + { + 'Resource${outer}${middle}${inner}': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { 'Fn::Sub': 'bucket-${outer}-${middle}-${inner}' }, + }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'nested-map-3.json'), JSON.stringify(nestedMap3, null, 2)); + + // Include chain base templates + const include1 = { + Level1: { + 'Fn::Include': 'file://' + path.join(fixturesDir, 'include-level2.json'), + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'include-level1.json'), JSON.stringify(include1, null, 2)); + + const include2 = { + Level2: { + 'Fn::Include': 'file://' + path.join(fixturesDir, 'include-level3.json'), + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'include-level2.json'), JSON.stringify(include2, null, 2)); + + const include3 = { + Level3: { + Value: 'deepest-level', + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'include-level3.json'), JSON.stringify(include3, null, 2)); + + // 10-deep include chain + for (let i = 1; i <= 10; i++) { + const content = + i < 10 + ? { [`Level${i}`]: { 'Fn::Include': `file://${path.join(fixturesDir, `deep-include-level${i + 1}.json`)}` } } + : { Level10: { Value: 'deepest' } }; + fs.writeFileSync(path.join(fixturesDir, `deep-include-level${i}.json`), JSON.stringify(content, null, 2)); + } + + // Create glob test files + const globDir = path.join(fixturesDir, 'glob-test'); + if (!fs.existsSync(globDir)) fs.mkdirSync(globDir, { recursive: true }); + + // 10 files for glob + for (let i = 0; i < 10; i++) { + fs.writeFileSync( + path.join(globDir, `resource-${i}.json`), + JSON.stringify({ [`Resource${i}`]: { Type: 'AWS::S3::Bucket' } }, null, 2), + ); + } + + // 100 files for glob + const globDir100 = path.join(fixturesDir, 'glob-test-100'); + if (!fs.existsSync(globDir100)) fs.mkdirSync(globDir100, { recursive: true }); + for (let i = 0; i < 100; i++) { + fs.writeFileSync( + path.join(globDir100, `resource-${i}.json`), + JSON.stringify({ [`Resource${i}`]: { Type: 'AWS::S3::Bucket' } }, null, 2), + ); + } + + // Complex template with multiple features + const complex = { + AWSTemplateFormatVersion: '2010-09-09', + Description: 'Complex benchmark template', + Parameters: { + Environment: { Type: 'String', Default: 'dev' }, + }, + Resources: { + 'Fn::Merge': [ + { + 'Fn::Map': [ + ['web', 'api', 'worker'], + 'service', + { + '${service}Bucket': { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { 'Fn::Sub': '${AWS::StackName}-${service}' }, + }, + }, + }, + ], + }, + { + 'Fn::Map': [ + { 'Fn::Sequence': [1, 5] }, + 'idx', + { + 'Lambda${idx}': { + Type: 'AWS::Lambda::Function', + Properties: { + FunctionName: { 'Fn::Sub': 'function-${idx}' }, + }, + }, + }, + ], + }, + ], + }, + Outputs: { + 'Fn::Merge': [ + { + 'Fn::Map': [ + ['web', 'api', 'worker'], + 'service', + { + '${service}Output': { + Value: { 'Fn::Ref': '${service}Bucket' }, + Export: { Name: { 'Fn::Sub': '${AWS::StackName}-${service}' } }, + }, + }, + ], + }, + ], + }, + }; + fs.writeFileSync(path.join(fixturesDir, 'complex.json'), JSON.stringify(complex, null, 2)); + + console.log(`${colors.green}✓ Generated fixture templates${colors.reset}\n`); +} + +/** + * Main benchmark suite + */ +async function main() { + console.log(`${colors.bright}cfn-include Performance Benchmark Suite${colors.reset}\n`); + console.log(`Node.js ${process.version}`); + console.log(`Platform: ${process.platform} ${process.arch}\n`); + + // Generate fixtures + generateFixtures(); + + const fixturesDir = path.join(__dirname, 'fixtures'); + const results = []; + + // 1. Simple template (baseline) + results.push( + await runBenchmark('Simple Template (baseline)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'simple.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'simple.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 2. Fn::Map with 10 items + results.push( + await runBenchmark('Fn::Map (10 items)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'map-10.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'map-10.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 3. Fn::Map with 100 items + results.push( + await runBenchmark('Fn::Map (100 items)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'map-100.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'map-100.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 4. Fn::Map with 1000 items + results.push( + await runBenchmark('Fn::Map (1000 items)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'map-1000.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'map-1000.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 5. Nested Map (3-deep) + results.push( + await runBenchmark('Nested Fn::Map (3-deep, 3x3x3=27 items)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'nested-map-3.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'nested-map-3.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 6. Include chain (3-deep) + results.push( + await runBenchmark('Fn::Include chain (3-deep)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'include-level1.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'include-level1.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 7. Include chain (10-deep) + results.push( + await runBenchmark('Fn::Include chain (10-deep)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'deep-include-level1.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'deep-include-level1.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 8. Glob with 10 files + const glob10Template = { + Resources: { + 'Fn::Merge': [ + { + 'Fn::Include': { + location: path.join(fixturesDir, 'glob-test', '*.json'), + isGlob: true, + }, + }, + ], + }, + }; + results.push( + await runBenchmark('Glob (10 files)', async () => { + await include({ + template: glob10Template, + url: `file://${fixturesDir}/glob-benchmark.json`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 9. Glob with 100 files + const glob100Template = { + Resources: { + 'Fn::Merge': [ + { + 'Fn::Include': { + location: path.join(fixturesDir, 'glob-test-100', '*.json'), + isGlob: true, + }, + }, + ], + }, + }; + results.push( + await runBenchmark('Glob (100 files)', async () => { + await include({ + template: glob100Template, + url: `file://${fixturesDir}/glob-benchmark.json`, + }); + }), + ); + printResult(results[results.length - 1]); + + // 10. Complex template + results.push( + await runBenchmark('Complex template (mixed features)', async () => { + await include({ + template: yaml.load(fs.readFileSync(path.join(fixturesDir, 'complex.json'), 'utf8')), + url: `file://${path.join(fixturesDir, 'complex.json')}`, + }); + }), + ); + printResult(results[results.length - 1]); + + // Summary + console.log(`${colors.bright}Summary${colors.reset}`); + console.log('─'.repeat(60)); + + const baselineAvg = results[0].avg; + for (const r of results) { + const ratio = r.avg / baselineAvg; + const ratioColor = ratio > 10 ? colors.red : ratio > 3 ? colors.yellow : colors.green; + console.log( + `${r.name.padEnd(40)} ${colors.green}${formatDuration(r.avg).padStart(12)}${colors.reset} ` + + `(${ratioColor}${ratio.toFixed(1)}x${colors.reset})`, + ); + } + + // Write results to JSON + const jsonResults = { + timestamp: new Date().toISOString(), + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + results: results.map((r) => ({ + name: r.name, + avgMs: r.avg, + minMs: r.min, + maxMs: r.max, + memoryDeltaBytes: r.memoryDelta, + })), + }; + + fs.writeFileSync(path.join(__dirname, 'results.json'), JSON.stringify(jsonResults, null, 2)); + console.log(`\n${colors.green}✓ Results saved to benchmarks/results.json${colors.reset}`); +} + +main().catch(console.error); diff --git a/benchmarks/fixtures/complex.json b/benchmarks/fixtures/complex.json new file mode 100644 index 0000000..816103e --- /dev/null +++ b/benchmarks/fixtures/complex.json @@ -0,0 +1,81 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Complex benchmark template", + "Parameters": { + "Environment": { + "Type": "String", + "Default": "dev" + } + }, + "Resources": { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "web", + "api", + "worker" + ], + "service", + { + "${service}Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "${AWS::StackName}-${service}" + } + } + } + } + ] + }, + { + "Fn::Map": [ + { + "Fn::Sequence": [ + 1, + 5 + ] + }, + "idx", + { + "Lambda${idx}": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": { + "Fn::Sub": "function-${idx}" + } + } + } + } + ] + } + ] + }, + "Outputs": { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "web", + "api", + "worker" + ], + "service", + { + "${service}Output": { + "Value": { + "Fn::Ref": "${service}Bucket" + }, + "Export": { + "Name": { + "Fn::Sub": "${AWS::StackName}-${service}" + } + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level1.json b/benchmarks/fixtures/deep-include-level1.json new file mode 100644 index 0000000..edc6281 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level1.json @@ -0,0 +1,5 @@ +{ + "Level1": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level2.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level10.json b/benchmarks/fixtures/deep-include-level10.json new file mode 100644 index 0000000..5e7d4b6 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level10.json @@ -0,0 +1,5 @@ +{ + "Level10": { + "Value": "deepest" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level2.json b/benchmarks/fixtures/deep-include-level2.json new file mode 100644 index 0000000..1676c37 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level2.json @@ -0,0 +1,5 @@ +{ + "Level2": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level3.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level3.json b/benchmarks/fixtures/deep-include-level3.json new file mode 100644 index 0000000..55db8a6 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level3.json @@ -0,0 +1,5 @@ +{ + "Level3": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level4.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level4.json b/benchmarks/fixtures/deep-include-level4.json new file mode 100644 index 0000000..97d8505 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level4.json @@ -0,0 +1,5 @@ +{ + "Level4": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level5.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level5.json b/benchmarks/fixtures/deep-include-level5.json new file mode 100644 index 0000000..c6f992f --- /dev/null +++ b/benchmarks/fixtures/deep-include-level5.json @@ -0,0 +1,5 @@ +{ + "Level5": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level6.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level6.json b/benchmarks/fixtures/deep-include-level6.json new file mode 100644 index 0000000..a0667ef --- /dev/null +++ b/benchmarks/fixtures/deep-include-level6.json @@ -0,0 +1,5 @@ +{ + "Level6": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level7.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level7.json b/benchmarks/fixtures/deep-include-level7.json new file mode 100644 index 0000000..4a70066 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level7.json @@ -0,0 +1,5 @@ +{ + "Level7": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level8.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level8.json b/benchmarks/fixtures/deep-include-level8.json new file mode 100644 index 0000000..37e7f9f --- /dev/null +++ b/benchmarks/fixtures/deep-include-level8.json @@ -0,0 +1,5 @@ +{ + "Level8": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level9.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/deep-include-level9.json b/benchmarks/fixtures/deep-include-level9.json new file mode 100644 index 0000000..b79fe62 --- /dev/null +++ b/benchmarks/fixtures/deep-include-level9.json @@ -0,0 +1,5 @@ +{ + "Level9": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/deep-include-level10.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-0.json b/benchmarks/fixtures/glob-test-100/resource-0.json new file mode 100644 index 0000000..edcb99b --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-0.json @@ -0,0 +1,5 @@ +{ + "Resource0": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-1.json b/benchmarks/fixtures/glob-test-100/resource-1.json new file mode 100644 index 0000000..a2f802f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-1.json @@ -0,0 +1,5 @@ +{ + "Resource1": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-10.json b/benchmarks/fixtures/glob-test-100/resource-10.json new file mode 100644 index 0000000..9616b71 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-10.json @@ -0,0 +1,5 @@ +{ + "Resource10": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-11.json b/benchmarks/fixtures/glob-test-100/resource-11.json new file mode 100644 index 0000000..0c96c65 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-11.json @@ -0,0 +1,5 @@ +{ + "Resource11": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-12.json b/benchmarks/fixtures/glob-test-100/resource-12.json new file mode 100644 index 0000000..3d2577d --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-12.json @@ -0,0 +1,5 @@ +{ + "Resource12": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-13.json b/benchmarks/fixtures/glob-test-100/resource-13.json new file mode 100644 index 0000000..eb99585 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-13.json @@ -0,0 +1,5 @@ +{ + "Resource13": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-14.json b/benchmarks/fixtures/glob-test-100/resource-14.json new file mode 100644 index 0000000..c860c97 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-14.json @@ -0,0 +1,5 @@ +{ + "Resource14": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-15.json b/benchmarks/fixtures/glob-test-100/resource-15.json new file mode 100644 index 0000000..221d0cd --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-15.json @@ -0,0 +1,5 @@ +{ + "Resource15": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-16.json b/benchmarks/fixtures/glob-test-100/resource-16.json new file mode 100644 index 0000000..ba5201c --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-16.json @@ -0,0 +1,5 @@ +{ + "Resource16": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-17.json b/benchmarks/fixtures/glob-test-100/resource-17.json new file mode 100644 index 0000000..58437ff --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-17.json @@ -0,0 +1,5 @@ +{ + "Resource17": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-18.json b/benchmarks/fixtures/glob-test-100/resource-18.json new file mode 100644 index 0000000..c5ede3f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-18.json @@ -0,0 +1,5 @@ +{ + "Resource18": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-19.json b/benchmarks/fixtures/glob-test-100/resource-19.json new file mode 100644 index 0000000..4714999 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-19.json @@ -0,0 +1,5 @@ +{ + "Resource19": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-2.json b/benchmarks/fixtures/glob-test-100/resource-2.json new file mode 100644 index 0000000..a53fb32 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-2.json @@ -0,0 +1,5 @@ +{ + "Resource2": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-20.json b/benchmarks/fixtures/glob-test-100/resource-20.json new file mode 100644 index 0000000..f3dad97 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-20.json @@ -0,0 +1,5 @@ +{ + "Resource20": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-21.json b/benchmarks/fixtures/glob-test-100/resource-21.json new file mode 100644 index 0000000..7f5a5a4 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-21.json @@ -0,0 +1,5 @@ +{ + "Resource21": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-22.json b/benchmarks/fixtures/glob-test-100/resource-22.json new file mode 100644 index 0000000..bfda8dc --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-22.json @@ -0,0 +1,5 @@ +{ + "Resource22": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-23.json b/benchmarks/fixtures/glob-test-100/resource-23.json new file mode 100644 index 0000000..71ad4ca --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-23.json @@ -0,0 +1,5 @@ +{ + "Resource23": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-24.json b/benchmarks/fixtures/glob-test-100/resource-24.json new file mode 100644 index 0000000..8a2aacb --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-24.json @@ -0,0 +1,5 @@ +{ + "Resource24": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-25.json b/benchmarks/fixtures/glob-test-100/resource-25.json new file mode 100644 index 0000000..cfcd250 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-25.json @@ -0,0 +1,5 @@ +{ + "Resource25": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-26.json b/benchmarks/fixtures/glob-test-100/resource-26.json new file mode 100644 index 0000000..81f6338 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-26.json @@ -0,0 +1,5 @@ +{ + "Resource26": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-27.json b/benchmarks/fixtures/glob-test-100/resource-27.json new file mode 100644 index 0000000..eeaf686 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-27.json @@ -0,0 +1,5 @@ +{ + "Resource27": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-28.json b/benchmarks/fixtures/glob-test-100/resource-28.json new file mode 100644 index 0000000..686b974 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-28.json @@ -0,0 +1,5 @@ +{ + "Resource28": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-29.json b/benchmarks/fixtures/glob-test-100/resource-29.json new file mode 100644 index 0000000..1ef4ea9 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-29.json @@ -0,0 +1,5 @@ +{ + "Resource29": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-3.json b/benchmarks/fixtures/glob-test-100/resource-3.json new file mode 100644 index 0000000..a9cb4d6 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-3.json @@ -0,0 +1,5 @@ +{ + "Resource3": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-30.json b/benchmarks/fixtures/glob-test-100/resource-30.json new file mode 100644 index 0000000..d49ec1b --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-30.json @@ -0,0 +1,5 @@ +{ + "Resource30": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-31.json b/benchmarks/fixtures/glob-test-100/resource-31.json new file mode 100644 index 0000000..efc758c --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-31.json @@ -0,0 +1,5 @@ +{ + "Resource31": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-32.json b/benchmarks/fixtures/glob-test-100/resource-32.json new file mode 100644 index 0000000..ad760c0 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-32.json @@ -0,0 +1,5 @@ +{ + "Resource32": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-33.json b/benchmarks/fixtures/glob-test-100/resource-33.json new file mode 100644 index 0000000..076da15 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-33.json @@ -0,0 +1,5 @@ +{ + "Resource33": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-34.json b/benchmarks/fixtures/glob-test-100/resource-34.json new file mode 100644 index 0000000..511007b --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-34.json @@ -0,0 +1,5 @@ +{ + "Resource34": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-35.json b/benchmarks/fixtures/glob-test-100/resource-35.json new file mode 100644 index 0000000..c5b39d9 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-35.json @@ -0,0 +1,5 @@ +{ + "Resource35": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-36.json b/benchmarks/fixtures/glob-test-100/resource-36.json new file mode 100644 index 0000000..d0b55b0 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-36.json @@ -0,0 +1,5 @@ +{ + "Resource36": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-37.json b/benchmarks/fixtures/glob-test-100/resource-37.json new file mode 100644 index 0000000..7a31142 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-37.json @@ -0,0 +1,5 @@ +{ + "Resource37": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-38.json b/benchmarks/fixtures/glob-test-100/resource-38.json new file mode 100644 index 0000000..d03148c --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-38.json @@ -0,0 +1,5 @@ +{ + "Resource38": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-39.json b/benchmarks/fixtures/glob-test-100/resource-39.json new file mode 100644 index 0000000..f20a853 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-39.json @@ -0,0 +1,5 @@ +{ + "Resource39": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-4.json b/benchmarks/fixtures/glob-test-100/resource-4.json new file mode 100644 index 0000000..a2387b9 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-4.json @@ -0,0 +1,5 @@ +{ + "Resource4": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-40.json b/benchmarks/fixtures/glob-test-100/resource-40.json new file mode 100644 index 0000000..65a029f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-40.json @@ -0,0 +1,5 @@ +{ + "Resource40": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-41.json b/benchmarks/fixtures/glob-test-100/resource-41.json new file mode 100644 index 0000000..bee71e2 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-41.json @@ -0,0 +1,5 @@ +{ + "Resource41": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-42.json b/benchmarks/fixtures/glob-test-100/resource-42.json new file mode 100644 index 0000000..e27b417 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-42.json @@ -0,0 +1,5 @@ +{ + "Resource42": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-43.json b/benchmarks/fixtures/glob-test-100/resource-43.json new file mode 100644 index 0000000..d2e5a7c --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-43.json @@ -0,0 +1,5 @@ +{ + "Resource43": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-44.json b/benchmarks/fixtures/glob-test-100/resource-44.json new file mode 100644 index 0000000..45fba5a --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-44.json @@ -0,0 +1,5 @@ +{ + "Resource44": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-45.json b/benchmarks/fixtures/glob-test-100/resource-45.json new file mode 100644 index 0000000..6d3aa22 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-45.json @@ -0,0 +1,5 @@ +{ + "Resource45": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-46.json b/benchmarks/fixtures/glob-test-100/resource-46.json new file mode 100644 index 0000000..6c4f182 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-46.json @@ -0,0 +1,5 @@ +{ + "Resource46": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-47.json b/benchmarks/fixtures/glob-test-100/resource-47.json new file mode 100644 index 0000000..9d04739 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-47.json @@ -0,0 +1,5 @@ +{ + "Resource47": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-48.json b/benchmarks/fixtures/glob-test-100/resource-48.json new file mode 100644 index 0000000..98a35de --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-48.json @@ -0,0 +1,5 @@ +{ + "Resource48": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-49.json b/benchmarks/fixtures/glob-test-100/resource-49.json new file mode 100644 index 0000000..8a82489 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-49.json @@ -0,0 +1,5 @@ +{ + "Resource49": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-5.json b/benchmarks/fixtures/glob-test-100/resource-5.json new file mode 100644 index 0000000..082da89 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-5.json @@ -0,0 +1,5 @@ +{ + "Resource5": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-50.json b/benchmarks/fixtures/glob-test-100/resource-50.json new file mode 100644 index 0000000..1089d60 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-50.json @@ -0,0 +1,5 @@ +{ + "Resource50": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-51.json b/benchmarks/fixtures/glob-test-100/resource-51.json new file mode 100644 index 0000000..db9dff3 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-51.json @@ -0,0 +1,5 @@ +{ + "Resource51": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-52.json b/benchmarks/fixtures/glob-test-100/resource-52.json new file mode 100644 index 0000000..519f277 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-52.json @@ -0,0 +1,5 @@ +{ + "Resource52": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-53.json b/benchmarks/fixtures/glob-test-100/resource-53.json new file mode 100644 index 0000000..cf394e8 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-53.json @@ -0,0 +1,5 @@ +{ + "Resource53": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-54.json b/benchmarks/fixtures/glob-test-100/resource-54.json new file mode 100644 index 0000000..592c2c0 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-54.json @@ -0,0 +1,5 @@ +{ + "Resource54": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-55.json b/benchmarks/fixtures/glob-test-100/resource-55.json new file mode 100644 index 0000000..75a1d66 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-55.json @@ -0,0 +1,5 @@ +{ + "Resource55": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-56.json b/benchmarks/fixtures/glob-test-100/resource-56.json new file mode 100644 index 0000000..79589a8 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-56.json @@ -0,0 +1,5 @@ +{ + "Resource56": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-57.json b/benchmarks/fixtures/glob-test-100/resource-57.json new file mode 100644 index 0000000..886f425 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-57.json @@ -0,0 +1,5 @@ +{ + "Resource57": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-58.json b/benchmarks/fixtures/glob-test-100/resource-58.json new file mode 100644 index 0000000..1a248f9 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-58.json @@ -0,0 +1,5 @@ +{ + "Resource58": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-59.json b/benchmarks/fixtures/glob-test-100/resource-59.json new file mode 100644 index 0000000..a0ab4fd --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-59.json @@ -0,0 +1,5 @@ +{ + "Resource59": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-6.json b/benchmarks/fixtures/glob-test-100/resource-6.json new file mode 100644 index 0000000..5f5a2ec --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-6.json @@ -0,0 +1,5 @@ +{ + "Resource6": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-60.json b/benchmarks/fixtures/glob-test-100/resource-60.json new file mode 100644 index 0000000..ed2d37d --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-60.json @@ -0,0 +1,5 @@ +{ + "Resource60": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-61.json b/benchmarks/fixtures/glob-test-100/resource-61.json new file mode 100644 index 0000000..eb408af --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-61.json @@ -0,0 +1,5 @@ +{ + "Resource61": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-62.json b/benchmarks/fixtures/glob-test-100/resource-62.json new file mode 100644 index 0000000..ba3296b --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-62.json @@ -0,0 +1,5 @@ +{ + "Resource62": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-63.json b/benchmarks/fixtures/glob-test-100/resource-63.json new file mode 100644 index 0000000..800a57a --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-63.json @@ -0,0 +1,5 @@ +{ + "Resource63": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-64.json b/benchmarks/fixtures/glob-test-100/resource-64.json new file mode 100644 index 0000000..008c1c3 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-64.json @@ -0,0 +1,5 @@ +{ + "Resource64": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-65.json b/benchmarks/fixtures/glob-test-100/resource-65.json new file mode 100644 index 0000000..b41670b --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-65.json @@ -0,0 +1,5 @@ +{ + "Resource65": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-66.json b/benchmarks/fixtures/glob-test-100/resource-66.json new file mode 100644 index 0000000..cfc0f1f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-66.json @@ -0,0 +1,5 @@ +{ + "Resource66": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-67.json b/benchmarks/fixtures/glob-test-100/resource-67.json new file mode 100644 index 0000000..9e70bc2 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-67.json @@ -0,0 +1,5 @@ +{ + "Resource67": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-68.json b/benchmarks/fixtures/glob-test-100/resource-68.json new file mode 100644 index 0000000..2abef01 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-68.json @@ -0,0 +1,5 @@ +{ + "Resource68": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-69.json b/benchmarks/fixtures/glob-test-100/resource-69.json new file mode 100644 index 0000000..1f6074f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-69.json @@ -0,0 +1,5 @@ +{ + "Resource69": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-7.json b/benchmarks/fixtures/glob-test-100/resource-7.json new file mode 100644 index 0000000..7d65200 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-7.json @@ -0,0 +1,5 @@ +{ + "Resource7": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-70.json b/benchmarks/fixtures/glob-test-100/resource-70.json new file mode 100644 index 0000000..156d19d --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-70.json @@ -0,0 +1,5 @@ +{ + "Resource70": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-71.json b/benchmarks/fixtures/glob-test-100/resource-71.json new file mode 100644 index 0000000..1cdf31e --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-71.json @@ -0,0 +1,5 @@ +{ + "Resource71": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-72.json b/benchmarks/fixtures/glob-test-100/resource-72.json new file mode 100644 index 0000000..65805fd --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-72.json @@ -0,0 +1,5 @@ +{ + "Resource72": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-73.json b/benchmarks/fixtures/glob-test-100/resource-73.json new file mode 100644 index 0000000..bd99791 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-73.json @@ -0,0 +1,5 @@ +{ + "Resource73": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-74.json b/benchmarks/fixtures/glob-test-100/resource-74.json new file mode 100644 index 0000000..9f7b2fb --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-74.json @@ -0,0 +1,5 @@ +{ + "Resource74": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-75.json b/benchmarks/fixtures/glob-test-100/resource-75.json new file mode 100644 index 0000000..a7f3643 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-75.json @@ -0,0 +1,5 @@ +{ + "Resource75": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-76.json b/benchmarks/fixtures/glob-test-100/resource-76.json new file mode 100644 index 0000000..9a47ef4 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-76.json @@ -0,0 +1,5 @@ +{ + "Resource76": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-77.json b/benchmarks/fixtures/glob-test-100/resource-77.json new file mode 100644 index 0000000..9c150a7 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-77.json @@ -0,0 +1,5 @@ +{ + "Resource77": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-78.json b/benchmarks/fixtures/glob-test-100/resource-78.json new file mode 100644 index 0000000..c47a9f1 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-78.json @@ -0,0 +1,5 @@ +{ + "Resource78": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-79.json b/benchmarks/fixtures/glob-test-100/resource-79.json new file mode 100644 index 0000000..14a3302 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-79.json @@ -0,0 +1,5 @@ +{ + "Resource79": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-8.json b/benchmarks/fixtures/glob-test-100/resource-8.json new file mode 100644 index 0000000..3ba9bca --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-8.json @@ -0,0 +1,5 @@ +{ + "Resource8": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-80.json b/benchmarks/fixtures/glob-test-100/resource-80.json new file mode 100644 index 0000000..12970e3 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-80.json @@ -0,0 +1,5 @@ +{ + "Resource80": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-81.json b/benchmarks/fixtures/glob-test-100/resource-81.json new file mode 100644 index 0000000..d4ff1fd --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-81.json @@ -0,0 +1,5 @@ +{ + "Resource81": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-82.json b/benchmarks/fixtures/glob-test-100/resource-82.json new file mode 100644 index 0000000..158f976 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-82.json @@ -0,0 +1,5 @@ +{ + "Resource82": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-83.json b/benchmarks/fixtures/glob-test-100/resource-83.json new file mode 100644 index 0000000..f80f327 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-83.json @@ -0,0 +1,5 @@ +{ + "Resource83": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-84.json b/benchmarks/fixtures/glob-test-100/resource-84.json new file mode 100644 index 0000000..09deef2 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-84.json @@ -0,0 +1,5 @@ +{ + "Resource84": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-85.json b/benchmarks/fixtures/glob-test-100/resource-85.json new file mode 100644 index 0000000..f3c69aa --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-85.json @@ -0,0 +1,5 @@ +{ + "Resource85": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-86.json b/benchmarks/fixtures/glob-test-100/resource-86.json new file mode 100644 index 0000000..01775f2 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-86.json @@ -0,0 +1,5 @@ +{ + "Resource86": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-87.json b/benchmarks/fixtures/glob-test-100/resource-87.json new file mode 100644 index 0000000..e2fc2ed --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-87.json @@ -0,0 +1,5 @@ +{ + "Resource87": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-88.json b/benchmarks/fixtures/glob-test-100/resource-88.json new file mode 100644 index 0000000..b23595e --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-88.json @@ -0,0 +1,5 @@ +{ + "Resource88": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-89.json b/benchmarks/fixtures/glob-test-100/resource-89.json new file mode 100644 index 0000000..0b51628 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-89.json @@ -0,0 +1,5 @@ +{ + "Resource89": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-9.json b/benchmarks/fixtures/glob-test-100/resource-9.json new file mode 100644 index 0000000..3c489fb --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-9.json @@ -0,0 +1,5 @@ +{ + "Resource9": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-90.json b/benchmarks/fixtures/glob-test-100/resource-90.json new file mode 100644 index 0000000..798f571 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-90.json @@ -0,0 +1,5 @@ +{ + "Resource90": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-91.json b/benchmarks/fixtures/glob-test-100/resource-91.json new file mode 100644 index 0000000..5b738de --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-91.json @@ -0,0 +1,5 @@ +{ + "Resource91": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-92.json b/benchmarks/fixtures/glob-test-100/resource-92.json new file mode 100644 index 0000000..a06141c --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-92.json @@ -0,0 +1,5 @@ +{ + "Resource92": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-93.json b/benchmarks/fixtures/glob-test-100/resource-93.json new file mode 100644 index 0000000..9c9b141 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-93.json @@ -0,0 +1,5 @@ +{ + "Resource93": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-94.json b/benchmarks/fixtures/glob-test-100/resource-94.json new file mode 100644 index 0000000..775050e --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-94.json @@ -0,0 +1,5 @@ +{ + "Resource94": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-95.json b/benchmarks/fixtures/glob-test-100/resource-95.json new file mode 100644 index 0000000..7edebf6 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-95.json @@ -0,0 +1,5 @@ +{ + "Resource95": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-96.json b/benchmarks/fixtures/glob-test-100/resource-96.json new file mode 100644 index 0000000..ed54991 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-96.json @@ -0,0 +1,5 @@ +{ + "Resource96": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-97.json b/benchmarks/fixtures/glob-test-100/resource-97.json new file mode 100644 index 0000000..229420f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-97.json @@ -0,0 +1,5 @@ +{ + "Resource97": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-98.json b/benchmarks/fixtures/glob-test-100/resource-98.json new file mode 100644 index 0000000..e14ce42 --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-98.json @@ -0,0 +1,5 @@ +{ + "Resource98": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test-100/resource-99.json b/benchmarks/fixtures/glob-test-100/resource-99.json new file mode 100644 index 0000000..a15f96f --- /dev/null +++ b/benchmarks/fixtures/glob-test-100/resource-99.json @@ -0,0 +1,5 @@ +{ + "Resource99": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-0.json b/benchmarks/fixtures/glob-test/resource-0.json new file mode 100644 index 0000000..edcb99b --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-0.json @@ -0,0 +1,5 @@ +{ + "Resource0": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-1.json b/benchmarks/fixtures/glob-test/resource-1.json new file mode 100644 index 0000000..a2f802f --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-1.json @@ -0,0 +1,5 @@ +{ + "Resource1": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-2.json b/benchmarks/fixtures/glob-test/resource-2.json new file mode 100644 index 0000000..a53fb32 --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-2.json @@ -0,0 +1,5 @@ +{ + "Resource2": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-3.json b/benchmarks/fixtures/glob-test/resource-3.json new file mode 100644 index 0000000..a9cb4d6 --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-3.json @@ -0,0 +1,5 @@ +{ + "Resource3": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-4.json b/benchmarks/fixtures/glob-test/resource-4.json new file mode 100644 index 0000000..a2387b9 --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-4.json @@ -0,0 +1,5 @@ +{ + "Resource4": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-5.json b/benchmarks/fixtures/glob-test/resource-5.json new file mode 100644 index 0000000..082da89 --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-5.json @@ -0,0 +1,5 @@ +{ + "Resource5": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-6.json b/benchmarks/fixtures/glob-test/resource-6.json new file mode 100644 index 0000000..5f5a2ec --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-6.json @@ -0,0 +1,5 @@ +{ + "Resource6": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-7.json b/benchmarks/fixtures/glob-test/resource-7.json new file mode 100644 index 0000000..7d65200 --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-7.json @@ -0,0 +1,5 @@ +{ + "Resource7": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-8.json b/benchmarks/fixtures/glob-test/resource-8.json new file mode 100644 index 0000000..3ba9bca --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-8.json @@ -0,0 +1,5 @@ +{ + "Resource8": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/glob-test/resource-9.json b/benchmarks/fixtures/glob-test/resource-9.json new file mode 100644 index 0000000..3c489fb --- /dev/null +++ b/benchmarks/fixtures/glob-test/resource-9.json @@ -0,0 +1,5 @@ +{ + "Resource9": { + "Type": "AWS::S3::Bucket" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/include-level1.json b/benchmarks/fixtures/include-level1.json new file mode 100644 index 0000000..5a602f5 --- /dev/null +++ b/benchmarks/fixtures/include-level1.json @@ -0,0 +1,5 @@ +{ + "Level1": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/include-level2.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/include-level2.json b/benchmarks/fixtures/include-level2.json new file mode 100644 index 0000000..ab72fb7 --- /dev/null +++ b/benchmarks/fixtures/include-level2.json @@ -0,0 +1,5 @@ +{ + "Level2": { + "Fn::Include": "file:///Users/nem/code/clawd/cfn-include/benchmarks/fixtures/include-level3.json" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/include-level3.json b/benchmarks/fixtures/include-level3.json new file mode 100644 index 0000000..c31b20a --- /dev/null +++ b/benchmarks/fixtures/include-level3.json @@ -0,0 +1,5 @@ +{ + "Level3": { + "Value": "deepest-level" + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/map-10.json b/benchmarks/fixtures/map-10.json new file mode 100644 index 0000000..645cc5b --- /dev/null +++ b/benchmarks/fixtures/map-10.json @@ -0,0 +1,33 @@ +{ + "Resources": { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "item0", + "item1", + "item2", + "item3", + "item4", + "item5", + "item6", + "item7", + "item8", + "item9" + ], + "$", + { + "Bucket${$}": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "bucket-${$}" + } + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/map-100.json b/benchmarks/fixtures/map-100.json new file mode 100644 index 0000000..8467d23 --- /dev/null +++ b/benchmarks/fixtures/map-100.json @@ -0,0 +1,123 @@ +{ + "Resources": { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "item0", + "item1", + "item2", + "item3", + "item4", + "item5", + "item6", + "item7", + "item8", + "item9", + "item10", + "item11", + "item12", + "item13", + "item14", + "item15", + "item16", + "item17", + "item18", + "item19", + "item20", + "item21", + "item22", + "item23", + "item24", + "item25", + "item26", + "item27", + "item28", + "item29", + "item30", + "item31", + "item32", + "item33", + "item34", + "item35", + "item36", + "item37", + "item38", + "item39", + "item40", + "item41", + "item42", + "item43", + "item44", + "item45", + "item46", + "item47", + "item48", + "item49", + "item50", + "item51", + "item52", + "item53", + "item54", + "item55", + "item56", + "item57", + "item58", + "item59", + "item60", + "item61", + "item62", + "item63", + "item64", + "item65", + "item66", + "item67", + "item68", + "item69", + "item70", + "item71", + "item72", + "item73", + "item74", + "item75", + "item76", + "item77", + "item78", + "item79", + "item80", + "item81", + "item82", + "item83", + "item84", + "item85", + "item86", + "item87", + "item88", + "item89", + "item90", + "item91", + "item92", + "item93", + "item94", + "item95", + "item96", + "item97", + "item98", + "item99" + ], + "$", + { + "Bucket${$}": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "bucket-${$}" + } + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/map-1000.json b/benchmarks/fixtures/map-1000.json new file mode 100644 index 0000000..61eecb5 --- /dev/null +++ b/benchmarks/fixtures/map-1000.json @@ -0,0 +1,1023 @@ +{ + "Resources": { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "item0", + "item1", + "item2", + "item3", + "item4", + "item5", + "item6", + "item7", + "item8", + "item9", + "item10", + "item11", + "item12", + "item13", + "item14", + "item15", + "item16", + "item17", + "item18", + "item19", + "item20", + "item21", + "item22", + "item23", + "item24", + "item25", + "item26", + "item27", + "item28", + "item29", + "item30", + "item31", + "item32", + "item33", + "item34", + "item35", + "item36", + "item37", + "item38", + "item39", + "item40", + "item41", + "item42", + "item43", + "item44", + "item45", + "item46", + "item47", + "item48", + "item49", + "item50", + "item51", + "item52", + "item53", + "item54", + "item55", + "item56", + "item57", + "item58", + "item59", + "item60", + "item61", + "item62", + "item63", + "item64", + "item65", + "item66", + "item67", + "item68", + "item69", + "item70", + "item71", + "item72", + "item73", + "item74", + "item75", + "item76", + "item77", + "item78", + "item79", + "item80", + "item81", + "item82", + "item83", + "item84", + "item85", + "item86", + "item87", + "item88", + "item89", + "item90", + "item91", + "item92", + "item93", + "item94", + "item95", + "item96", + "item97", + "item98", + "item99", + "item100", + "item101", + "item102", + "item103", + "item104", + "item105", + "item106", + "item107", + "item108", + "item109", + "item110", + "item111", + "item112", + "item113", + "item114", + "item115", + "item116", + "item117", + "item118", + "item119", + "item120", + "item121", + "item122", + "item123", + "item124", + "item125", + "item126", + "item127", + "item128", + "item129", + "item130", + "item131", + "item132", + "item133", + "item134", + "item135", + "item136", + "item137", + "item138", + "item139", + "item140", + "item141", + "item142", + "item143", + "item144", + "item145", + "item146", + "item147", + "item148", + "item149", + "item150", + "item151", + "item152", + "item153", + "item154", + "item155", + "item156", + "item157", + "item158", + "item159", + "item160", + "item161", + "item162", + "item163", + "item164", + "item165", + "item166", + "item167", + "item168", + "item169", + "item170", + "item171", + "item172", + "item173", + "item174", + "item175", + "item176", + "item177", + "item178", + "item179", + "item180", + "item181", + "item182", + "item183", + "item184", + "item185", + "item186", + "item187", + "item188", + "item189", + "item190", + "item191", + "item192", + "item193", + "item194", + "item195", + "item196", + "item197", + "item198", + "item199", + "item200", + "item201", + "item202", + "item203", + "item204", + "item205", + "item206", + "item207", + "item208", + "item209", + "item210", + "item211", + "item212", + "item213", + "item214", + "item215", + "item216", + "item217", + "item218", + "item219", + "item220", + "item221", + "item222", + "item223", + "item224", + "item225", + "item226", + "item227", + "item228", + "item229", + "item230", + "item231", + "item232", + "item233", + "item234", + "item235", + "item236", + "item237", + "item238", + "item239", + "item240", + "item241", + "item242", + "item243", + "item244", + "item245", + "item246", + "item247", + "item248", + "item249", + "item250", + "item251", + "item252", + "item253", + "item254", + "item255", + "item256", + "item257", + "item258", + "item259", + "item260", + "item261", + "item262", + "item263", + "item264", + "item265", + "item266", + "item267", + "item268", + "item269", + "item270", + "item271", + "item272", + "item273", + "item274", + "item275", + "item276", + "item277", + "item278", + "item279", + "item280", + "item281", + "item282", + "item283", + "item284", + "item285", + "item286", + "item287", + "item288", + "item289", + "item290", + "item291", + "item292", + "item293", + "item294", + "item295", + "item296", + "item297", + "item298", + "item299", + "item300", + "item301", + "item302", + "item303", + "item304", + "item305", + "item306", + "item307", + "item308", + "item309", + "item310", + "item311", + "item312", + "item313", + "item314", + "item315", + "item316", + "item317", + "item318", + "item319", + "item320", + "item321", + "item322", + "item323", + "item324", + "item325", + "item326", + "item327", + "item328", + "item329", + "item330", + "item331", + "item332", + "item333", + "item334", + "item335", + "item336", + "item337", + "item338", + "item339", + "item340", + "item341", + "item342", + "item343", + "item344", + "item345", + "item346", + "item347", + "item348", + "item349", + "item350", + "item351", + "item352", + "item353", + "item354", + "item355", + "item356", + "item357", + "item358", + "item359", + "item360", + "item361", + "item362", + "item363", + "item364", + "item365", + "item366", + "item367", + "item368", + "item369", + "item370", + "item371", + "item372", + "item373", + "item374", + "item375", + "item376", + "item377", + "item378", + "item379", + "item380", + "item381", + "item382", + "item383", + "item384", + "item385", + "item386", + "item387", + "item388", + "item389", + "item390", + "item391", + "item392", + "item393", + "item394", + "item395", + "item396", + "item397", + "item398", + "item399", + "item400", + "item401", + "item402", + "item403", + "item404", + "item405", + "item406", + "item407", + "item408", + "item409", + "item410", + "item411", + "item412", + "item413", + "item414", + "item415", + "item416", + "item417", + "item418", + "item419", + "item420", + "item421", + "item422", + "item423", + "item424", + "item425", + "item426", + "item427", + "item428", + "item429", + "item430", + "item431", + "item432", + "item433", + "item434", + "item435", + "item436", + "item437", + "item438", + "item439", + "item440", + "item441", + "item442", + "item443", + "item444", + "item445", + "item446", + "item447", + "item448", + "item449", + "item450", + "item451", + "item452", + "item453", + "item454", + "item455", + "item456", + "item457", + "item458", + "item459", + "item460", + "item461", + "item462", + "item463", + "item464", + "item465", + "item466", + "item467", + "item468", + "item469", + "item470", + "item471", + "item472", + "item473", + "item474", + "item475", + "item476", + "item477", + "item478", + "item479", + "item480", + "item481", + "item482", + "item483", + "item484", + "item485", + "item486", + "item487", + "item488", + "item489", + "item490", + "item491", + "item492", + "item493", + "item494", + "item495", + "item496", + "item497", + "item498", + "item499", + "item500", + "item501", + "item502", + "item503", + "item504", + "item505", + "item506", + "item507", + "item508", + "item509", + "item510", + "item511", + "item512", + "item513", + "item514", + "item515", + "item516", + "item517", + "item518", + "item519", + "item520", + "item521", + "item522", + "item523", + "item524", + "item525", + "item526", + "item527", + "item528", + "item529", + "item530", + "item531", + "item532", + "item533", + "item534", + "item535", + "item536", + "item537", + "item538", + "item539", + "item540", + "item541", + "item542", + "item543", + "item544", + "item545", + "item546", + "item547", + "item548", + "item549", + "item550", + "item551", + "item552", + "item553", + "item554", + "item555", + "item556", + "item557", + "item558", + "item559", + "item560", + "item561", + "item562", + "item563", + "item564", + "item565", + "item566", + "item567", + "item568", + "item569", + "item570", + "item571", + "item572", + "item573", + "item574", + "item575", + "item576", + "item577", + "item578", + "item579", + "item580", + "item581", + "item582", + "item583", + "item584", + "item585", + "item586", + "item587", + "item588", + "item589", + "item590", + "item591", + "item592", + "item593", + "item594", + "item595", + "item596", + "item597", + "item598", + "item599", + "item600", + "item601", + "item602", + "item603", + "item604", + "item605", + "item606", + "item607", + "item608", + "item609", + "item610", + "item611", + "item612", + "item613", + "item614", + "item615", + "item616", + "item617", + "item618", + "item619", + "item620", + "item621", + "item622", + "item623", + "item624", + "item625", + "item626", + "item627", + "item628", + "item629", + "item630", + "item631", + "item632", + "item633", + "item634", + "item635", + "item636", + "item637", + "item638", + "item639", + "item640", + "item641", + "item642", + "item643", + "item644", + "item645", + "item646", + "item647", + "item648", + "item649", + "item650", + "item651", + "item652", + "item653", + "item654", + "item655", + "item656", + "item657", + "item658", + "item659", + "item660", + "item661", + "item662", + "item663", + "item664", + "item665", + "item666", + "item667", + "item668", + "item669", + "item670", + "item671", + "item672", + "item673", + "item674", + "item675", + "item676", + "item677", + "item678", + "item679", + "item680", + "item681", + "item682", + "item683", + "item684", + "item685", + "item686", + "item687", + "item688", + "item689", + "item690", + "item691", + "item692", + "item693", + "item694", + "item695", + "item696", + "item697", + "item698", + "item699", + "item700", + "item701", + "item702", + "item703", + "item704", + "item705", + "item706", + "item707", + "item708", + "item709", + "item710", + "item711", + "item712", + "item713", + "item714", + "item715", + "item716", + "item717", + "item718", + "item719", + "item720", + "item721", + "item722", + "item723", + "item724", + "item725", + "item726", + "item727", + "item728", + "item729", + "item730", + "item731", + "item732", + "item733", + "item734", + "item735", + "item736", + "item737", + "item738", + "item739", + "item740", + "item741", + "item742", + "item743", + "item744", + "item745", + "item746", + "item747", + "item748", + "item749", + "item750", + "item751", + "item752", + "item753", + "item754", + "item755", + "item756", + "item757", + "item758", + "item759", + "item760", + "item761", + "item762", + "item763", + "item764", + "item765", + "item766", + "item767", + "item768", + "item769", + "item770", + "item771", + "item772", + "item773", + "item774", + "item775", + "item776", + "item777", + "item778", + "item779", + "item780", + "item781", + "item782", + "item783", + "item784", + "item785", + "item786", + "item787", + "item788", + "item789", + "item790", + "item791", + "item792", + "item793", + "item794", + "item795", + "item796", + "item797", + "item798", + "item799", + "item800", + "item801", + "item802", + "item803", + "item804", + "item805", + "item806", + "item807", + "item808", + "item809", + "item810", + "item811", + "item812", + "item813", + "item814", + "item815", + "item816", + "item817", + "item818", + "item819", + "item820", + "item821", + "item822", + "item823", + "item824", + "item825", + "item826", + "item827", + "item828", + "item829", + "item830", + "item831", + "item832", + "item833", + "item834", + "item835", + "item836", + "item837", + "item838", + "item839", + "item840", + "item841", + "item842", + "item843", + "item844", + "item845", + "item846", + "item847", + "item848", + "item849", + "item850", + "item851", + "item852", + "item853", + "item854", + "item855", + "item856", + "item857", + "item858", + "item859", + "item860", + "item861", + "item862", + "item863", + "item864", + "item865", + "item866", + "item867", + "item868", + "item869", + "item870", + "item871", + "item872", + "item873", + "item874", + "item875", + "item876", + "item877", + "item878", + "item879", + "item880", + "item881", + "item882", + "item883", + "item884", + "item885", + "item886", + "item887", + "item888", + "item889", + "item890", + "item891", + "item892", + "item893", + "item894", + "item895", + "item896", + "item897", + "item898", + "item899", + "item900", + "item901", + "item902", + "item903", + "item904", + "item905", + "item906", + "item907", + "item908", + "item909", + "item910", + "item911", + "item912", + "item913", + "item914", + "item915", + "item916", + "item917", + "item918", + "item919", + "item920", + "item921", + "item922", + "item923", + "item924", + "item925", + "item926", + "item927", + "item928", + "item929", + "item930", + "item931", + "item932", + "item933", + "item934", + "item935", + "item936", + "item937", + "item938", + "item939", + "item940", + "item941", + "item942", + "item943", + "item944", + "item945", + "item946", + "item947", + "item948", + "item949", + "item950", + "item951", + "item952", + "item953", + "item954", + "item955", + "item956", + "item957", + "item958", + "item959", + "item960", + "item961", + "item962", + "item963", + "item964", + "item965", + "item966", + "item967", + "item968", + "item969", + "item970", + "item971", + "item972", + "item973", + "item974", + "item975", + "item976", + "item977", + "item978", + "item979", + "item980", + "item981", + "item982", + "item983", + "item984", + "item985", + "item986", + "item987", + "item988", + "item989", + "item990", + "item991", + "item992", + "item993", + "item994", + "item995", + "item996", + "item997", + "item998", + "item999" + ], + "$", + { + "Bucket${$}": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "bucket-${$}" + } + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/nested-map-3.json b/benchmarks/fixtures/nested-map-3.json new file mode 100644 index 0000000..549bb14 --- /dev/null +++ b/benchmarks/fixtures/nested-map-3.json @@ -0,0 +1,54 @@ +{ + "Resources": { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "a", + "b", + "c" + ], + "outer", + { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + "x", + "y", + "z" + ], + "middle", + { + "Fn::Merge": [ + { + "Fn::Map": [ + [ + 1, + 2, + 3 + ], + "inner", + { + "Resource${outer}${middle}${inner}": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "bucket-${outer}-${middle}-${inner}" + } + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/benchmarks/fixtures/simple.json b/benchmarks/fixtures/simple.json new file mode 100644 index 0000000..6c40648 --- /dev/null +++ b/benchmarks/fixtures/simple.json @@ -0,0 +1,12 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Simple benchmark template", + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "my-simple-bucket" + } + } + } +} \ No newline at end of file diff --git a/benchmarks/results.json b/benchmarks/results.json new file mode 100644 index 0000000..e7ca6a5 --- /dev/null +++ b/benchmarks/results.json @@ -0,0 +1,78 @@ +{ + "timestamp": "2026-02-08T21:30:17.706Z", + "nodeVersion": "v22.22.0", + "platform": "darwin", + "arch": "arm64", + "results": [ + { + "name": "Simple Template (baseline)", + "avgMs": 0.1845002000000079, + "minMs": 0.06662500000001614, + "maxMs": 0.5414580000000058, + "memoryDeltaBytes": 232672 + }, + { + "name": "Fn::Map (10 items)", + "avgMs": 0.6623668000000009, + "minMs": 0.4798330000000135, + "maxMs": 1.0785419999999988, + "memoryDeltaBytes": -2764688 + }, + { + "name": "Fn::Map (100 items)", + "avgMs": 3.284158400000007, + "minMs": 2.569041999999996, + "maxMs": 4.212250000000012, + "memoryDeltaBytes": -1487784 + }, + { + "name": "Fn::Map (1000 items)", + "avgMs": 24.232258399999996, + "minMs": 23.28054199999997, + "maxMs": 25.349625000000003, + "memoryDeltaBytes": 4106544 + }, + { + "name": "Nested Fn::Map (3-deep, 3x3x3=27 items)", + "avgMs": 2.2009915999999747, + "minMs": 1.9771669999999517, + "maxMs": 2.525457999999958, + "memoryDeltaBytes": -12797680 + }, + { + "name": "Fn::Include chain (3-deep)", + "avgMs": 0.186258399999997, + "minMs": 0.11037499999997635, + "maxMs": 0.2673340000000053, + "memoryDeltaBytes": 446288 + }, + { + "name": "Fn::Include chain (10-deep)", + "avgMs": 0.40524180000001025, + "minMs": 0.38433400000002393, + "maxMs": 0.45150000000001, + "memoryDeltaBytes": 2360632 + }, + { + "name": "Glob (10 files)", + "avgMs": 0.006899799999996503, + "minMs": 0.004416999999989457, + "maxMs": 0.013374999999996362, + "memoryDeltaBytes": 42712 + }, + { + "name": "Glob (100 files)", + "avgMs": 0.006166600000005929, + "minMs": 0.004166000000054737, + "maxMs": 0.01208299999996143, + "memoryDeltaBytes": 42712 + }, + { + "name": "Complex template (mixed features)", + "avgMs": 0.4172166000000175, + "minMs": 0.3972090000000321, + "maxMs": 0.43558300000000827, + "memoryDeltaBytes": 3683552 + } + ] +} \ No newline at end of file diff --git a/bin/cli.js b/bin/cli.js deleted file mode 100755 index 3ee9d93..0000000 --- a/bin/cli.js +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable global-require, no-console */ -const exec = require('child_process').execSync; -const path = require('path'); -const _ = require('lodash'); -const pathParse = require('path-parse'); -const yargs = require('yargs'); - -const include = require('../index'); -const yaml = require('../lib/yaml'); -const Client = require('../lib/cfnclient'); -const pkg = require('../package.json'); -const replaceEnv = require('../lib/replaceEnv'); - -yargs.version(false); - -const { env } = process; -// eslint-disable-next-line global-require -const opts = yargs - .command('$0 [path] [options]', pkg.description, (y) => - y.positional('path', { - positional: true, - desc: 'location of template. Either path to a local file, URL or file on an S3 bucket (e.g. s3://bucket-name/example.template)', - required: false, - }) - ) - .options({ - minimize: { - desc: 'minimize JSON output', - default: false, - boolean: true, - alias: 'm', - }, - metadata: { - desc: 'add build metadata to output', - default: false, - boolean: true, - }, - validate: { - desc: 'validate compiled template', - default: false, - boolean: true, - alias: 't', - }, - yaml: { - desc: 'output yaml instead of json', - default: false, - boolean: true, - alias: 'y', - }, - lineWidth: { - desc: 'output yaml line width', - default: 200, - number: true, - alias: 'l', - }, - bucket: { - desc: 'bucket name required for templates larger than 50k', - }, - context: { - desc: - // eslint-disable-next-line max-len - 'template full path. only utilized for stdin when the template is piped to this script', - required: false, - string: true, - }, - prefix: { - desc: 'prefix for templates uploaded to the bucket', - default: 'cfn-include', - }, - enable: { - string: true, - desc: `enable different options: ['env','eval'] or a combination of both via comma.`, - choices: ['', 'env', 'env,eval', 'eval,env', 'eval'], // '' hack - default: '', - }, - inject: { - alias: 'i', - string: true, - // eslint-disable-next-line max-len - desc: `JSON string payload to use for template injection.`, - coerce: (valStr) => JSON.parse(valStr), - }, - doLog: { - boolean: true, - // eslint-disable-next-line max-len - desc: `console log out include options in recurse step`, - }, - version: { - boolean: true, - desc: 'print version and exit', - callback() { - console.log(pkg.version); - process.exit(0); - }, - }, - }) - .parse(); - -// make enable an array -opts.enable = opts.enable.split(','); - -let promise; -if (opts.path) { - let location; - const protocol = opts.path.match(/^\w+:\/\//); - if (protocol) location = opts.path; - else if (pathParse(opts.path).root) location = `file://${opts.path}`; - else location = `file://${path.join(process.cwd(), opts.path)}`; - promise = include({ - url: location, - doEnv: opts.enable.includes('env'), - doEval: opts.enable.includes('eval'), - inject: opts.inject, - doLog: opts.doLog, - }); -} else { - promise = new Promise((resolve, reject) => { - process.stdin.setEncoding('utf8'); - const rawData = []; - process.stdin.on('data', (chunk) => rawData.push(chunk)); - process.stdin.on('error', (err) => reject(err)); - process.stdin.on('end', () => resolve(rawData.join(''))); - }).then((template) => { - if (template.length === 0) { - console.error('empty template received from stdin'); - process.exit(1); - } - - const location = opts.context - ? path.resolve(opts.context) - : path.join(process.cwd(), 'template.yml'); - - template = opts.enable.includes('env') ? replaceEnv(template) : template; - - return include({ - template: yaml.load(template), - url: `file://${location}`, - doEnv: opts.enable.includes('env'), - doEval: opts.enable.includes('eval'), - inject: opts.inject, - doLog: opts.doLog, - }).catch((err) => console.error(err)); - }); -} - -promise - .then(function (template) { - if (opts.metadata) { - let stdout; - try { - stdout = exec('git log -n 1 --pretty=%H', { - stdio: [0, 'pipe', 'ignore'], - }) - .toString() - .trim(); - } catch (e) { - // eslint-disable-next-line no-empty - } - _.defaultsDeep(template, { - Metadata: { - CfnInclude: { - GitCommit: stdout, - BuildDate: new Date().toISOString(), - }, - }, - }); - } - if (opts.validate) { - const cfn = new Client({ - region: env.AWS_REGION || env.AWS_DEFAULT_REGION || 'us-east-1', - bucket: opts.bucket, - prefix: opts.prefix, - }); - return cfn.validateTemplate(JSON.stringify(template)).then(() => template); - } - return template; - }) - .then((template) => { - console.log( - opts.yaml - ? yaml.dump(template, opts) - : JSON.stringify(template, null, opts.minimize ? null : 2) - ); - }) - .catch(function (err) { - if (typeof err.toString === 'function') console.error(err.toString()); - else console.error(err); - console.log(err.stack); - process.exit(1); - }); diff --git a/commitlint.config.js b/commitlint.config.cjs similarity index 58% rename from commitlint.config.js rename to commitlint.config.cjs index f8e4a00..02c84a8 100644 --- a/commitlint.config.js +++ b/commitlint.config.cjs @@ -2,5 +2,6 @@ module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'body-max-line-length': [2, 'always', 200], + 'subject-case': [0, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], }, }; diff --git a/docs/PHASE1-PERFORMANCE-ANALYSIS.md b/docs/PHASE1-PERFORMANCE-ANALYSIS.md new file mode 100644 index 0000000..ac1166a --- /dev/null +++ b/docs/PHASE1-PERFORMANCE-ANALYSIS.md @@ -0,0 +1,1092 @@ +# Phase 1: Performance Optimization Analysis + +**Date:** 2026-02-08 +**Repo:** brickhouse-tech/cfn-include +**Version:** 2.1.18 + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Current Architecture Analysis](#current-architecture-analysis) +3. [Recursive Call Flow Documentation](#recursive-call-flow-documentation) +4. [Performance Hotspots](#performance-hotspots) +5. [Optimization Avenues](#optimization-avenues) +6. [Iterative vs Recursive Evaluation](#iterative-vs-recursive-evaluation) +7. [Test Coverage Gaps](#test-coverage-gaps) +8. [Benchmark Suite](#benchmark-suite) +9. [Recommendations](#recommendations) + +--- + +## Executive Summary + +cfn-include is a CloudFormation template preprocessor supporting ~30+ custom intrinsic functions including `Fn::Include`, `Fn::Map`, `Fn::Merge`, and more. The codebase uses a deeply recursive architecture that processes templates by walking the object tree and handling special functions. + +### Key Findings + +1. **Heavy Recursion:** The `recurse()` function is called for every node in the template tree, with each `Fn::Map` iteration spawning additional recursive calls +2. **Excessive Object Cloning:** `_.clone(scope)` and `_.cloneDeep(body)` in `Fn::Map` cause O(n²) memory behavior +3. **Bluebird Overhead:** Using Bluebird when native Promises are now highly optimized +4. **No Memoization:** File includes are re-read and re-parsed even when identical +5. **Synchronous Glob:** `globSync` blocks the event loop during file discovery + +### Estimated Impact Summary + +| Optimization | Effort | Impact | Risk | +|--------------|--------|--------|------| +| Replace Bluebird with native | Medium | 10-20% | Low | +| Add file/parse memoization | Low | 20-40% | Low | +| Reduce object cloning | High | 30-50% | Medium | +| Parallel file I/O | Medium | 15-25% | Low | +| Lodash tree-shaking | Low | 5-10% | Low | +| Regex pre-compilation | Low | 5-15% | Low | + +--- + +## Current Architecture Analysis + +### Core Files + +``` +index.js (33KB) - Main entry, recurse(), all Fn:: handlers +lib/ +├── promise.js - Bluebird wrapper for mapX +├── replaceEnv.js - Environment variable substitution +├── schema.js - YAML schema with custom tags +├── yaml.js - YAML/JSON parsing wrapper +├── parselocation.js - URL/path parsing +├── request.js - HTTP fetching +├── internals.js - AWS pseudo-parameters, ARN building +├── cfnclient.js - CloudFormation client wrapper +├── utils.js - camelCase utilities +└── include/ + └── query.js - JMESPath/Lodash query parsers +``` + +### Entry Point Flow + +```javascript +module.exports = async function (options) { + // 1. Parse options and base location + const base = parseLocation(options.url); + + // 2. Load template if not provided + template = _.isUndefined(template) + ? fnInclude({ base, scope, cft: options.url, ...options }) + : template; + + // 3. Resolve and begin recursion + const resolvedTemplate = await Promise.resolve(template); + return recurse({ base, scope, cft: resolvedTemplate, rootTemplate: resolvedTemplate, ...options }); +}; +``` + +--- + +## Recursive Call Flow Documentation + +### Main Recursion: `recurse()` + +The `recurse()` function is the heart of template processing. It handles: + +1. **Arrays:** Maps over each element recursively +2. **Plain Objects:** Checks for ~30 `Fn::*` handlers, then recurses into all values +3. **Primitives:** Applies `replaceEnv()` substitution + +``` +recurse({base, scope, cft, rootTemplate, ...opts}) + │ + ├── Array? ──► Promise.all(cft.map(o => recurse(..., cft: o))) + │ + ├── PlainObject? + │ ├── Fn::Map? ──► mapX(list, (replace) => { + │ │ scope = _.clone(scope) + │ │ replaced = findAndReplace(scope, _.cloneDeep(body)) + │ │ return recurse(..., cft: replaced) + │ │ }) + │ │ .then(results => recurse(..., cft: results)) + │ │ + │ ├── Fn::Include? ──► fnInclude(...).then(json => { + │ │ _.defaults(cft, json) + │ │ return recurse(...) + │ │ }) + │ │ + │ ├── Fn::Merge? ──► recurse(cft['Fn::Merge']).then(json => { + │ │ return recurse(_.defaults(cft, _.merge(...json))) + │ │ }) + │ │ + │ ├── [27 more Fn:: handlers...] + │ │ + │ └── Default ──► Promise.props(_.mapValues(cft, (v, k) => recurse(..., cft: v))) + │ + └── Primitive? ──► replaceEnv(cft, opts.inject, opts.doEnv) +``` + +### Fn::Include Sub-Flow + +``` +fnInclude({base, scope, cft, ...opts}) + │ + ├── fnIncludeOpts(cft) - Parse location, query, parser from various formats + │ + ├── parseLocation() - Determine protocol (file/s3/http) + │ + ├── File Protocol: + │ ├── isGlob()? ──► globSync() + build recursive template + │ └── readFile() ──► String ──► procTemplate (replaceEnv) + │ + ├── S3 Protocol: + │ └── s3.send(GetObjectCommand) ──► procTemplate + │ + ├── HTTP Protocol: + │ └── request() ──► procTemplate + │ + └── handleIncludeBody() + └── type === 'json'? ──► yaml.load() ──► loopTemplate() ──► query() + where loopTemplate = recursive call to recurse() +``` + +### findAndReplace Sub-Flow + +``` +findAndReplace(scope, object) + │ + ├── String exact match? ──► Replace with scope value + │ + ├── String pattern? ──► new RegExp(`\${${find}}`, 'g') for each scope key + │ object.replace(regex, replace) + │ + ├── Array? ──► object.map(findAndReplace.bind(this, scope)) + │ + └── PlainObject? ──► _.mapKeys + forEach keys (skip 'Fn::Map') + ──► findAndReplace(scope, object[key]) +``` + +--- + +## Performance Hotspots + +### 1. `Fn::Map` Processing (Critical) + +**Location:** `index.js:103-129` + +```javascript +if (cft['Fn::Map']) { + // ... + return PromiseExt.mapX(recurse(..., cft: list), (replace, key) => { + scope = _.clone(scope); // ⚠️ Clone for each iteration + scope[placeholder] = replace; + const replaced = findAndReplace(scope, _.cloneDeep(body)); // ⚠️ Deep clone body + return recurse({ base, scope, cft: replaced, ...opts }); + }).then((_cft) => { + if (hassize) { + _cft = findAndReplace({ [sz]: _cft.length }, _cft); + } + return recurse(..., cft: _cft); // ⚠️ Another full recursion + }); +} +``` + +**Problems:** +- `_.clone(scope)`: Creates new scope object for each iteration +- `_.cloneDeep(body)`: Deep clones the entire body template for each iteration +- Double recursion: First processes items, then recurses on combined result +- For 1000-item map with nested body: O(n * depth * bodySize) clones + +**Impact:** ~40% of processing time in Map-heavy templates + +### 2. `findAndReplace` Regex Creation (High) + +**Location:** `index.js:503-533` + +```javascript +function findAndReplace(scope, object) { + // ... + _.forEach(scope, function (replace, find) { + const regex = new RegExp(`\\\${${find}}`, 'g'); // ⚠️ Regex created per scope key + if (find !== '_' && object.match(regex)) { + object = object.replace(regex, replace); + } + }); + // ... +} +``` + +**Problems:** +- Creates new RegExp for every scope key, for every string in template +- No regex caching/memoization +- Called recursively for every node + +**Impact:** ~15% of processing time + +### 3. Bluebird Promise Overhead (Medium) + +**Location:** `lib/promise.js`, `index.js:6` + +```javascript +const Promise = require('bluebird'); +``` + +**Problems:** +- Bluebird adds ~30KB bundle size +- Native Promises now optimized in Node 20+ +- `Promise.props` and `Promise.map` can be replaced with native alternatives + +**Impact:** ~10-15% overhead vs native + +### 4. YAML Parsing (Medium) + +**Location:** `lib/yaml.js` + +```javascript +load: (res) => { + let json; + try { + json = yaml.load(res, { schema: yamlSchema }); // ⚠️ Schema created each call + } catch (yamlErr) { + // fallback to JSON.parse + } + return json; +}; +``` + +**Problems:** +- Schema object passed on every call +- No caching of parsed results for identical content + +**Impact:** ~10% for include-heavy templates + +### 5. `replaceEnv` in Hot Path (Medium) + +**Location:** `lib/replaceEnv.js` + +```javascript +const replaceEnv = (template, inject = {}, doEnv) => { + // ... + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + template = template + .replace(new RegExp(`\\$${key}`, "g"), val) // ⚠️ Two regex per key + .replace(new RegExp(`\\$\{${key}}`, "g"), val); + } + return processTemplate(template); +}; +``` + +**Problems:** +- Creates 2 regex per key per call +- Called for every string node in template + +**Impact:** ~10% of processing time + +### 6. Synchronous Glob (Medium) + +**Location:** `index.js` (multiple locations) + +```javascript +const globs = globSync(absolute).sort(); // ⚠️ Blocks event loop +``` + +**Problems:** +- `globSync` blocks during file system traversal +- Sort operation on potentially large arrays + +**Impact:** Variable based on file system, can be significant with many files + +### 7. No File Caching (Medium) + +**Location:** `fnInclude()` function + +```javascript +body = readFile(absolute).then(String).then(procTemplate); +``` + +**Problems:** +- Same file read multiple times if included from different places +- No content hash caching +- No parsed template caching + +**Impact:** ~20% for templates with repeated includes + +--- + +## Optimization Avenues + +### 1. Promise Handling: Bluebird vs Native + +**Current State:** +- Uses Bluebird for `Promise.props`, `Promise.map`, `Promise.try` +- Bluebird adds overhead and bundle size + +**Proposed Changes:** +```javascript +// Replace Promise.props with: +async function promiseProps(obj) { + const keys = Object.keys(obj); + const values = await Promise.all(Object.values(obj)); + return Object.fromEntries(keys.map((k, i) => [k, values[i]])); +} + +// Replace Promise.map with: +const results = await Promise.all(array.map(fn)); + +// Replace Promise.try with: +Promise.resolve().then(fn); +``` + +**Estimated Impact:** 10-20% improvement +**Effort:** Medium +**Risk:** Low (straightforward replacement) + +--- + +### 2. Lodash Usage + +**Current Usage Analysis:** + +| Function | Occurrences | Native Alternative | +|----------|-------------|-------------------| +| `_.isArray` | 5 | `Array.isArray` | +| `_.isPlainObject` | 12 | Custom check | +| `_.isString` | 6 | `typeof x === 'string'` | +| `_.isUndefined` | 2 | `x === undefined` | +| `_.clone` | 3 | `{...obj}` or structured clone | +| `_.cloneDeep` | 3 | `structuredClone()` (Node 17+) | +| `_.mapValues` | 1 | `Object.fromEntries(Object.entries().map())` | +| `_.mapKeys` | 1 | `Object.fromEntries(Object.entries().map())` | +| `_.forEach` | 8 | `for...of` or `.forEach()` | +| `_.defaults` | 4 | `Object.assign({}, defaults, obj)` | +| `_.merge` | 4 | Custom deep merge | +| `_.flatten` | 1 | `array.flat()` | +| `_.flattenDeep` | 1 | `array.flat(Infinity)` | +| `_.uniq` | 1 | `[...new Set(array)]` | +| `_.compact` | 1 | `array.filter(Boolean)` | +| `_.concat` | 1 | `[...arr1, ...arr2]` | +| `_.sortBy` | 1 | Keep lodash (complex) | +| `_.sortedUniq` | 1 | Custom implementation | +| `_.without` | 1 | `array.filter(x => !set.has(x))` | +| `_.omit` | 1 | Object destructuring | +| `_.omitBy` | 1 | `Object.fromEntries(Object.entries().filter())` | +| `_.bind` | 1 | Arrow function | +| `_.fromPairs` | 1 | `Object.fromEntries` | +| `_.escapeRegExp` | 1 | Custom function | + +**Proposed Changes:** +1. Replace simple type checks with native +2. Replace `_.clone` with spread operator +3. Replace `_.cloneDeep` with `structuredClone()` +4. Keep lodash only for complex operations like `_.sortBy` + +**Estimated Impact:** 5-10% (mostly bundle size) +**Effort:** Low-Medium +**Risk:** Low + +--- + +### 3. Object Cloning Patterns — THE PRIMARY BOTTLENECK + +The `Fn::Map` handler has two distinct cloning operations that cause the 71x slowdown: + +```javascript +// In Fn::Map handler - called N times per map +scope = _.clone(scope); // Problem A: Scope cloning +const replaced = findAndReplace(scope, _.cloneDeep(body)); // Problem B: Body cloning +``` + +These are **separate problems** requiring **separate solutions**: + +--- + +#### Problem A: Scope Cloning (`_.clone(scope)`) + +**What's happening:** Every `Fn::Map` iteration clones the entire scope object to add one variable. + +**The math:** 1000-item map with 10 scope variables = 10,000 object property copies. + +**Solution: Lazy Scope via Prototype Chain** + +Instead of cloning, use JavaScript's native prototype chain for O(1) child scope creation: + +```javascript +// Option 1: Native Object.create() - ZERO DEPENDENCIES +function childScope(parent, additions) { + const child = Object.create(parent); + Object.assign(child, additions); + return child; +} +// Lookup automatically walks prototype chain + +// Option 2: Simple class (if you need iteration support) +class ScopeChain { + constructor(parent = null) { + this.parent = parent; + this.vars = Object.create(null); + } + get(key) { return key in this.vars ? this.vars[key] : this.parent?.get(key); } + set(key, val) { this.vars[key] = val; } + child(additions) { + const c = new ScopeChain(this); + Object.assign(c.vars, additions); + return c; + } +} +``` + +**Existing Libraries:** None needed — `Object.create()` is native and optimal. + +**Impact:** O(1) instead of O(scope size) per iteration +**Effort:** Low (localized change) +**Risk:** Low — prototype chains are fundamental JS + +--- + +#### Problem B: Body Cloning (`_.cloneDeep(body)`) + +**What's happening:** Every `Fn::Map` iteration deep-clones the entire body template before substitution. + +**The math:** 1000-item map with 500-node body = 500,000 object clones. + +**Solution Options:** + +**Option 1: Immer (Structural Sharing)** + +[Immer](https://immerjs.github.io/immer/) uses copy-on-write — unchanged parts share memory: + +```javascript +import { produce } from 'immer'; + +// Only nodes that actually change get cloned +const replaced = produce(body, draft => { + substituteVariablesInPlace(draft, scope); +}); +``` + +- **Pros:** Battle-tested, handles nested structures, used by Redux Toolkit +- **Cons:** Adds ~15KB dependency, slight overhead for simple cases +- **Best for:** Complex bodies where most nodes don't need substitution + +**Option 2: Variable Slot Analysis (Custom)** + +Analyze the body once to find which paths need substitution: + +```javascript +// Run once per unique body structure +const slots = analyzeVariableSlots(body); +// Returns: [{ path: ['nested', 'key'], pattern: '${_}' }, ...] + +// Then for each iteration, only touch those paths +const replaced = substituteSlots(body, slots, scope); +``` + +- **Pros:** Maximum performance, no dependency +- **Cons:** More code to maintain, edge cases with dynamic structures +- **Best for:** When body structure is static and predictable + +**Option 3: Lazy Cloning with Proxy** + +Clone nodes only when they're actually modified: + +```javascript +function lazyClone(obj) { + let cloned = null; + return new Proxy(obj, { + set(target, prop, value) { + if (!cloned) cloned = Array.isArray(obj) ? [...obj] : { ...obj }; + cloned[prop] = value; + return true; + }, + get(target, prop) { + return cloned ? cloned[prop] : target[prop]; + } + }); +} +``` + +- **Pros:** Zero upfront cost, clones only what changes +- **Cons:** Proxy overhead on every access, complex for deep structures +- **Best for:** Shallow bodies with few substitutions + +--- + +#### RECOMMENDATION: Do Both, In Order + +1. **Phase 1a: Lazy Scope (Week 1)** + - Implement `Object.create()` based scope chain + - Drop-in replacement, low risk + - Expected gain: 10-20% + +2. **Phase 1b: Body Optimization (Week 2-3)** + - Start with **Immer** — it's proven and handles edge cases + - Benchmark against current + - If needed, optimize further with slot analysis + - Expected gain: 20-40% + +**Combined Impact:** 30-50% +**Total Effort:** Medium (phased approach reduces risk) +**Risk:** Low → Medium (Immer is safe; custom slot analysis needs thorough testing) + +--- + +### 4. File I/O Patterns + +**Current Issues:** +- No caching of file reads +- Sequential reads for includes +- Synchronous glob operations + +**Proposed Solutions:** + +1. **File Content Cache:** +```javascript +const fileCache = new Map(); + +async function cachedReadFile(path) { + const cached = fileCache.get(path); + if (cached) { + const stat = await fs.stat(path); + if (stat.mtimeMs === cached.mtime) { + return cached.content; + } + } + const content = await fs.readFile(path, 'utf8'); + const stat = await fs.stat(path); + fileCache.set(path, { content, mtime: stat.mtimeMs }); + return content; +} +``` + +2. **Parallel Include Processing:** +```javascript +// When processing array of includes, batch reads +const includeUrls = extractAllIncludes(template); +const contents = await Promise.all(includeUrls.map(loadContent)); +const contentMap = new Map(includeUrls.map((url, i) => [url, contents[i]])); +// Then process template with contentMap +``` + +3. **Async Glob:** +```javascript +import { glob } from 'glob'; // async version +const paths = await glob(pattern); +``` + +**Estimated Impact:** 15-25% +**Effort:** Medium +**Risk:** Low + +--- + +### 5. YAML/JSON Parsing Optimization + +**Proposed Solutions:** + +1. **Parsed Template Cache:** +```javascript +const parseCache = new Map(); + +function cachedParse(content) { + const hash = crypto.createHash('md5').update(content).digest('hex'); + if (parseCache.has(hash)) { + return structuredClone(parseCache.get(hash)); + } + const parsed = yaml.load(content, { schema: yamlSchema }); + parseCache.set(hash, parsed); + return parsed; +} +``` + +2. **Schema Singleton:** +```javascript +// Already done, but ensure yamlSchema is created once +const yamlSchema = yaml.DEFAULT_SCHEMA.extend(tags); // in schema.js +``` + +**Estimated Impact:** 10-15% +**Effort:** Low +**Risk:** Low + +--- + +### 6. Regex Pre-compilation + +**Current Problem:** + +```javascript +// In findAndReplace - called many times +const regex = new RegExp(`\\\${${find}}`, 'g'); + +// In replaceEnv +template.replace(new RegExp(`\\$${key}`, "g"), val) + .replace(new RegExp(`\\$\{${key}}`, "g"), val); +``` + +**Proposed Solution:** + +```javascript +// Pre-compile common patterns +const regexCache = new Map(); + +function getCachedRegex(pattern, flags = 'g') { + const key = `${pattern}:${flags}`; + if (!regexCache.has(key)) { + regexCache.set(key, new RegExp(pattern, flags)); + } + return regexCache.get(key); +} + +// Or pre-compile for known scope keys +function precompilePatterns(scope) { + const patterns = {}; + for (const key of Object.keys(scope)) { + patterns[key] = { + exact: new RegExp(`^${escapeRegExp(key)}$`), + interpolated: new RegExp(`\\$\\{${escapeRegExp(key)}\\}`, 'g'), + }; + } + return patterns; +} +``` + +**Estimated Impact:** 5-15% +**Effort:** Low +**Risk:** Low + +--- + +### 7. Scope Variable Propagation + +**Current Approach:** +- Clone scope object at each level +- Pass scope through all recursive calls + +**Proposed Optimization:** + +```javascript +// Use a scope chain instead of cloning +class ScopeChain { + constructor(parent = null) { + this.parent = parent; + this.vars = new Map(); + } + + get(key) { + return this.vars.has(key) ? this.vars.get(key) : this.parent?.get(key); + } + + set(key, value) { + this.vars.set(key, value); + } + + child() { + return new ScopeChain(this); + } + + // Fast lookup for findAndReplace + *entries() { + for (const [k, v] of this.vars) yield [k, v]; + if (this.parent) yield* this.parent.entries(); + } +} +``` + +**Estimated Impact:** 10-15% +**Effort:** Medium +**Risk:** Medium (behavior changes) + +--- + +### 8. Memoization Opportunities + +**Identified Targets:** + +1. **`fnInclude` results by absolute path + inject hash:** +```javascript +const includeCache = new Map(); + +async function memoizedFnInclude(opts) { + const key = `${opts.absolute}:${hashObject(opts.inject)}`; + if (includeCache.has(key)) { + return structuredClone(includeCache.get(key)); + } + const result = await fnIncludeImpl(opts); + includeCache.set(key, result); + return result; +} +``` + +2. **`isTaggableResource` results:** +```javascript +// Already uses cache internally, verify it's working +``` + +3. **`buildResourceArn` for same resource types:** +```javascript +// Low-frequency, not worth memoizing +``` + +4. **Query parser results:** +```javascript +const queryCache = new Map(); +function cachedQuery(parser, template, query) { + const key = `${parser}:${JSON.stringify(query)}`; + // Tricky because template varies... +} +``` + +**Estimated Impact:** 20-40% (mainly from include caching) +**Effort:** Low-Medium +**Risk:** Low + +--- + +### 9. Worker Threads for Parallel Processing + +**Potential Use Cases:** + +1. **Independent Fn::Map iterations:** + - Each iteration is independent + - Could process in parallel workers + +2. **Multiple Fn::Include files:** + - File reading and parsing can be parallelized + +**Challenges:** +- Scope sharing across workers is complex +- Message serialization overhead +- Small templates may not benefit + +**Proposed Architecture:** + +```javascript +import { Worker, isMainThread, parentPort, workerData } from 'worker_threads'; + +// For large maps (>100 items), spawn workers +if (mapItems.length > WORKER_THRESHOLD) { + const chunks = chunkArray(mapItems, WORKER_COUNT); + const workers = chunks.map(chunk => + new Worker('./map-worker.js', { workerData: { chunk, body, scope } }) + ); + const results = await Promise.all(workers.map(w => + new Promise((resolve, reject) => { + w.on('message', resolve); + w.on('error', reject); + }) + )); + return results.flat(); +} +``` + +**Estimated Impact:** 30-50% for very large maps +**Effort:** High +**Risk:** High (complexity, debugging difficulty) + +--- + +## Iterative vs Recursive Evaluation + +### Current Recursive Nature + +The current implementation uses deep recursion because: + +1. **Fn::Map nesting:** Maps can contain Maps indefinitely +2. **Fn::Include chains:** Includes can include files with more Includes +3. **Scope inheritance:** Variables must flow down the tree +4. **Post-processing:** Many Fn:: results need further recursion + +### Feasibility of Iterative Approach + +**Could Convert to Iterative:** +- Simple array/object traversal +- Single-level Fn::Map +- Fn::Flatten, Fn::Uniq, etc. + +**Must Remain Recursive (or use explicit stack):** +- Nested Fn::Map with scope inheritance +- Fn::Include with query chaining +- Fn::Merge with nested structures + +**Proposed Hybrid Approach:** + +```javascript +async function processTemplate(template, context) { + const stack = [{ node: template, path: [], context }]; + const results = new Map(); + + while (stack.length > 0) { + const { node, path, context } = stack.pop(); + + if (isPrimitive(node)) { + results.set(path, replaceEnv(node, context)); + continue; + } + + if (Array.isArray(node)) { + for (let i = node.length - 1; i >= 0; i--) { + stack.push({ node: node[i], path: [...path, i], context }); + } + continue; + } + + // Handle Fn:: functions + const fnKey = Object.keys(node).find(k => k.startsWith('Fn::')); + if (fnKey) { + // Some Fn:: handlers must use recursion + if (FN_NEEDS_RECURSION.has(fnKey)) { + const result = await handleFnRecursive(fnKey, node[fnKey], context); + results.set(path, result); + } else { + // Others can be processed iteratively + stack.push(...expandFnIterative(fnKey, node[fnKey], path, context)); + } + continue; + } + + // Regular object + const keys = Object.keys(node); + for (const key of keys.reverse()) { + stack.push({ node: node[key], path: [...path, key], context }); + } + } + + return assembleResults(results); +} +``` + +**Assessment:** +- **Feasibility:** Partial - some functions inherently recursive +- **Benefit:** Avoids stack overflow on very deep templates +- **Effort:** Very High (complete rewrite) +- **Risk:** High (many edge cases) +- **Recommendation:** Keep recursive, add stack depth monitoring + +--- + +## Test Coverage Gaps + +### Currently Tested + +Based on `t/tests/` analysis: + +| Feature | Test File | Coverage | +|---------|-----------|----------| +| Basic includes | location.json | Good | +| Fn::Map basics | map.json | Good | +| Extended maps | extendedmaps.json | Good | +| Globs | globs.json | Basic | +| Merge/DeepMerge | merge.json, deepmerge.yml | Good | +| Environment vars | env.js | Good | +| Fn::Eval/IfEval | eval.js, ifeval.js | Good | +| Fn::RefNow | refNow.js | Extensive | +| Fn::ApplyTags | applyTags.yml | Good | + +### Missing Test Coverage + +#### 1. Edge Cases Not Tested + +```javascript +// Missing tests for: +- Circular include detection (if any exists) +- Very deep nesting (>50 levels) +- Very large maps (>1000 items) +- Empty maps [] +- Maps with undefined/null items +- Include with failing network request recovery +- S3 permission errors +- Concurrent includes of same file +- Scope collision in nested maps with same placeholder +- Unicode in scope variables +- Binary file includes +- Include chain with mixed protocols (file → s3 → http) +``` + +#### 2. Integration Tests Needed + +```javascript +// Complex nested scenarios not tested: +describe('Complex Integration', () => { + it('should handle Fn::Map within Fn::Include within Fn::Map', () => { + // Template that uses map to include files that themselves use maps + }); + + it('should handle Fn::Merge of Fn::Map results with scope preservation', () => { + // Ensure scope variables survive merge operations + }); + + it('should handle parallel includes with shared scope', () => { + // Multiple includes in same object, all using same scope + }); + + it('should handle Fn::RefNow in deeply nested Fn::Map', () => { + // RefNow resolution in complex nested structure + }); + + it('should handle Fn::Include with Fn::DeepMerge override chains', () => { + // Include → override → include → override patterns + }); +}); +``` + +#### 3. Regression Test Suite Needed + +```javascript +// Before any refactoring, create regression suite: +describe('Regression Suite', () => { + const testCases = loadAllTestCases('./regression-fixtures/'); + + testCases.forEach(({ input, expectedOutput, description }) => { + it(description, async () => { + const result = await include({ template: input, url: 'file:///test.json' }); + assert.deepEqual(result, expectedOutput); + }); + }); +}); +``` + +#### 4. Performance Regression Tests + +```javascript +describe('Performance Baselines', () => { + it('should process 100-item map in under 100ms', async () => { + const start = performance.now(); + await include({ template: map100Template, url: '...' }); + expect(performance.now() - start).toBeLessThan(100); + }); + + it('should process 10-deep include in under 50ms', async () => { + // ... + }); + + it('should not exceed 50MB memory for 1000-item map', async () => { + const before = process.memoryUsage().heapUsed; + await include({ template: map1000Template, url: '...' }); + const delta = process.memoryUsage().heapUsed - before; + expect(delta).toBeLessThan(50 * 1024 * 1024); + }); +}); +``` + +--- + +## Benchmark Suite + +A benchmark suite has been created at `benchmarks/benchmark-runner.js`. + +### Running Benchmarks + +```bash +# Basic run +node benchmarks/benchmark-runner.js + +# With GC exposed for accurate memory measurement +node --expose-gc benchmarks/benchmark-runner.js +``` + +### Benchmark Cases + +1. **Simple Template (baseline)** - Minimal template, no custom functions +2. **Fn::Map (10/100/1000 items)** - Scaling behavior +3. **Nested Fn::Map (3-deep)** - Recursion overhead +4. **Fn::Include chain (3/10-deep)** - Include resolution overhead +5. **Glob (10/100 files)** - File discovery overhead +6. **Complex template** - Mixed real-world scenario + +### Expected Results Format + +```json +{ + "timestamp": "2026-02-08T17:26:00.000Z", + "nodeVersion": "v22.22.0", + "platform": "darwin", + "arch": "arm64", + "results": [ + { + "name": "Simple Template (baseline)", + "avgMs": 5.23, + "minMs": 4.81, + "maxMs": 6.12, + "memoryDeltaBytes": 102400 + } + ] +} +``` + +--- + +## Recommendations + +### Phase 1a: Quick Wins (1-2 days) + +1. **Add file content caching** to `fnInclude` +2. **Pre-compile regex patterns** in `findAndReplace` and `replaceEnv` +3. **Replace simple lodash calls** with native equivalents +4. **Switch to async glob** (`glob` package already supports it) + +### Phase 1b: Medium Effort (1 week) + +1. **Replace Bluebird** with native Promise utilities +2. **Implement scope chain** instead of clone per iteration +3. **Add parsed template cache** with content hashing +4. **Parallelize independent includes** + +### Phase 1c: Major Refactoring (2-3 weeks) + +1. **Reduce cloning in Fn::Map** with structural analysis +2. **Implement worker thread pool** for large maps +3. **Add comprehensive regression test suite** +4. **Profile and iterate** on remaining hotspots + +### Before Any Changes + +1. ✅ Create benchmark suite (done) +2. Create comprehensive regression test suite +3. Document expected behavior for edge cases +4. Set up CI with performance regression checks + +--- + +## Appendix: Code Snippets for Reference + +### Native Promise.props Replacement + +```javascript +async function promiseProps(obj) { + const entries = Object.entries(obj); + const values = await Promise.all(entries.map(([_, v]) => Promise.resolve(v))); + return Object.fromEntries(entries.map(([k], i) => [k, values[i]])); +} +``` + +### Native isPlainObject + +```javascript +function isPlainObject(value) { + if (typeof value !== 'object' || value === null) return false; + const proto = Object.getPrototypeOf(value); + return proto === null || proto === Object.prototype; +} +``` + +### Regex Pre-compilation + +```javascript +const regexCache = new Map(); + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getVarRegex(key) { + const cacheKey = `var:${key}`; + if (!regexCache.has(cacheKey)) { + regexCache.set(cacheKey, new RegExp(`\\$\\{${escapeRegExp(key)}\\}`, 'g')); + } + return regexCache.get(cacheKey); +} +``` + +--- + +*Document generated: 2026-02-08* +*Author: TARS (Performance Analysis Agent)* diff --git a/docs/PHASE2-ESM-ANALYSIS.md b/docs/PHASE2-ESM-ANALYSIS.md new file mode 100644 index 0000000..1dd3090 --- /dev/null +++ b/docs/PHASE2-ESM-ANALYSIS.md @@ -0,0 +1,560 @@ +# PHASE 2: ES6/ESM Conversion Analysis + +**Date:** 2025-02-08 +**Repository:** brickhouse-tech/cfn-include +**Package:** @znemz/cfn-include@2.1.18 + +--- + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Current CommonJS Patterns](#current-commonjs-patterns) +3. [Dependency ESM Compatibility](#dependency-esm-compatibility) +4. [Conversion Challenges](#conversion-challenges) +5. [File-by-File Conversion Checklist](#file-by-file-conversion-checklist) +6. [ESM Migration Plan](#esm-migration-plan) +7. [Testing Strategy](#testing-strategy) + +--- + +## Executive Summary + +This analysis covers the conversion of cfn-include from CommonJS to ES6 Modules (ESM). The codebase consists of: +- **12 source files** (index.js, bin/cli.js, lib/*.js) +- **7 test files** (t/*.js, t/tests/*.js) + +**Key Findings:** +- ✅ No `__dirname`/`__filename` usage in source files (only in tests) +- ✅ No dynamic `require()` calls that would complicate ESM +- ⚠️ The CLI already uses dynamic `import()` for yargs +- ⚠️ One deprecated dependency (`lib/include/api.js` uses `aws-sdk-proxy` - legacy SDK v2) +- ✅ All major dependencies have ESM support or can be replaced + +--- + +## Current CommonJS Patterns + +### 1. All `require()` Statements + +#### `/index.js` (Main Entry Point) +```javascript +const url = require('url'); // Node builtin +const path = require('path'); // Node builtin +const { readFile } = require('fs/promises'); // Node builtin +const _ = require('lodash'); // Has ESM +const { globSync } = require('glob'); // Has ESM +const Promise = require('bluebird'); // ⚠️ No ESM, needs replacement +const sortObject = require('@znemz/sort-object'); // Internal package +const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); // Has ESM +const { addProxyToClient } = require('aws-sdk-v3-proxy'); // Has ESM +const pathParse = require('path-parse'); // CJS only, needs replacement +const deepMerge = require('deepmerge'); // Has ESM +const { isTaggableResource } = require('@znemz/cft-utils/src/resources/taggable'); // Internal +const request = require('./lib/request'); // Local +const PromiseExt = require('./lib/promise'); // Local +const yaml = require('./lib/yaml'); // Local +const { getParser } = require('./lib/include/query'); // Local +const parseLocation = require('./lib/parselocation'); // Local +const replaceEnv = require('./lib/replaceEnv'); // Local +const { lowerCamelCase, upperCamelCase } = require('./lib/utils'); // Local +const { isOurExplicitFunction } = require('./lib/schema'); // Local +const { getAwsPseudoParameters, buildResourceArn } = require('./lib/internals'); // Local +``` + +#### `/bin/cli.js` +```javascript +const exec = require('child_process').execSync; // Node builtin +const path = require('path'); // Node builtin +const _ = require('lodash'); // Has ESM +const pathParse = require('path-parse'); // CJS only +const include = require('../index'); // Local +const yaml = require('../lib/yaml'); // Local +const Client = require('../lib/cfnclient'); // Local +const pkg = require('../package.json'); // ⚠️ JSON import +const replaceEnv = require('../lib/replaceEnv'); // Local + +// Already uses dynamic import for ESM packages: +const { default: yargs } = await import('yargs'); +const { hideBin } = await import('yargs/helpers'); +``` + +#### `/lib/request.js` +```javascript +var url = require('url'); // Node builtin +var https = require('https'); // Node builtin +var http = require('http'); // Node builtin +``` + +#### `/lib/promise.js` +```javascript +const Promise = require('bluebird'); // ⚠️ No ESM +const _ = require('lodash'); // Has ESM +``` + +#### `/lib/include/query.js` +```javascript +const { get } = require("lodash"); // Has ESM +const { search } = require("jmespath"); // Has ESM +``` + +#### `/lib/include/api.js` (⚠️ DEPRECATED/UNUSED) +```javascript +let AWS = require('aws-sdk-proxy'); // ⚠️ Legacy SDK v2! +let jmespath = require('jmespath'); // Has ESM +``` +> **Note:** This file appears unused. It references the deprecated aws-sdk-proxy (v2 SDK). Should be removed or rewritten for v3. + +#### `/lib/internals.js` +```javascript +// No requires - pure functions only +``` + +#### `/lib/schema.js` +```javascript +var yaml = require('js-yaml'); // Has ESM +var _ = require('lodash'); // Has ESM +``` + +#### `/lib/utils.js` +```javascript +const assert = require('assert').strict; // Node builtin +``` + +#### `/lib/yaml.js` +```javascript +const minify = require('jsonminify'); // CJS only +const yaml = require('js-yaml'); // Has ESM +const yamlSchema = require('./schema'); // Local +``` + +#### `/lib/replaceEnv.js` +```javascript +// No requires - pure functions only +``` + +#### `/lib/parselocation.js` +```javascript +var { isUndefined } = require('lodash'); // Has ESM +``` + +#### `/lib/cfnclient.js` +```javascript +const { CloudFormationClient, ValidateTemplateCommand } = require('@aws-sdk/client-cloudformation'); // Has ESM +const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); // Has ESM +const { addProxyToClient } = require('aws-sdk-v3-proxy'); // Has ESM +const { posix: path } = require('path'); // Node builtin +const crypto = require('crypto'); // Node builtin +``` + +### 2. All `module.exports` Usage + +| File | Export Style | +|------|-------------| +| `/index.js` | `module.exports = async function (options) { ... }` | +| `/bin/cli.js` | N/A (CLI entry point, no exports) | +| `/lib/request.js` | `module.exports = function(location) { ... }` | +| `/lib/promise.js` | `module.exports = { mapWhatever, mapX }` | +| `/lib/include/query.js` | `module.exports = { getParser }` | +| `/lib/include/api.js` | `module.exports = function (args) { ... }` | +| `/lib/internals.js` | `module.exports = { getAwsPseudoParameters, buildResourceArn }` | +| `/lib/schema.js` | `module.exports = yaml.DEFAULT_SCHEMA.extend(tags);` + additional exports | +| `/lib/utils.js` | `module.exports = { lowerCamelCase, upperCamelCase }` | +| `/lib/yaml.js` | `module.exports = { load, dump }` | +| `/lib/replaceEnv.js` | `module.exports = replaceEnv;` + `replaceEnv.IsRegExVar = IsRegExVar;` | +| `/lib/parselocation.js` | `module.exports = function parseLocation(location) { ... }` | +| `/lib/cfnclient.js` | `module.exports = Client;` | + +### 3. Dynamic Requires Analysis + +**Good news:** There are **no problematic dynamic requires** in the source code. + +The only dynamic-looking pattern is in the test files: +```javascript +// t/include.js - This is a computed test file path +require(`./tests/${file}`) + +// t/cli.js - Same pattern for test loading +require(`./tests/${file}.json`) +``` + +These test patterns can be converted to dynamic `import()` easily. + +### 4. Circular Dependency Analysis + +**Dependency Graph:** +``` +index.js +├── lib/request.js (no deps on lib/) +├── lib/promise.js (no deps on lib/) +├── lib/yaml.js (depends on lib/schema.js) +│ └── lib/schema.js (no deps on lib/) +├── lib/include/query.js (no deps on lib/) +├── lib/parselocation.js (no deps on lib/) +├── lib/replaceEnv.js (no deps on lib/) +├── lib/utils.js (no deps on lib/) +├── lib/schema.js (no deps on lib/) +└── lib/internals.js (no deps on lib/) + +bin/cli.js +├── index.js (circular OK - no issue at runtime) +├── lib/yaml.js +├── lib/cfnclient.js +│ └── (no deps on lib/) +└── lib/replaceEnv.js +``` + +**Result: No circular dependencies exist.** + +--- + +## Dependency ESM Compatibility + +### NPM Dependencies Analysis + +| Package | Version | ESM Support | Recommendation | +|---------|---------|-------------|----------------| +| `lodash` | ^4.17.21 | ✅ Via `lodash-es` | Replace with `lodash-es` or cherry-pick imports | +| `bluebird` | ^3.7.2 | ❌ No ESM | **Replace with native Promise** | +| `js-yaml` | ^4.1.1 | ✅ Has ESM | Direct import works | +| `glob` | ^13.0.0 | ✅ Pure ESM | Already compatible | +| `deepmerge` | ^4.2.2 | ✅ Has ESM | Direct import works | +| `jmespath` | ^0.16.0 | ✅ Has ESM | Direct import works | +| `jsonminify` | ^0.4.1 | ❌ CJS only | Replace with `minify-json` or inline | +| `path-parse` | ~1.0.7 | ❌ CJS only | **Replace with Node `path.parse()`** | +| `@aws-sdk/client-s3` | ^3.637.0 | ✅ Full ESM | Direct import works | +| `@aws-sdk/client-cloudformation` | ^3.637.0 | ✅ Full ESM | Direct import works | +| `aws-sdk-v3-proxy` | 2.2.0 | ✅ Has ESM | Direct import works | +| `@znemz/sort-object` | ^3.0.4 | ⚠️ Check | Internal package, convert as needed | +| `@znemz/cft-utils` | 0.1.33 | ⚠️ Check | Internal package, convert as needed | +| `yargs` | ~18.0.0 | ✅ Pure ESM | Already imported dynamically | + +### Replacements Needed + +#### 1. `bluebird` → Native Promise + Custom Helpers +Bluebird is used for: +- `Promise.props()` - Map object values to promises +- `Promise.map()` - Concurrent array mapping +- `Promise.try()` - Safe promise start + +**Native replacements:** +```javascript +// Promise.props equivalent +async function promiseProps(obj) { + const entries = Object.entries(obj); + const values = await Promise.all(entries.map(([, v]) => v)); + return Object.fromEntries(entries.map(([k], i) => [k, values[i]])); +} + +// Promise.map equivalent (with concurrency) +async function promiseMap(arr, fn, { concurrency = Infinity } = {}) { + if (concurrency === Infinity) return Promise.all(arr.map(fn)); + const results = []; + for (let i = 0; i < arr.length; i += concurrency) { + const chunk = arr.slice(i, i + concurrency); + results.push(...await Promise.all(chunk.map((item, j) => fn(item, i + j)))); + } + return results; +} + +// Promise.try equivalent +const promiseTry = (fn) => new Promise((resolve) => resolve(fn())); +``` + +#### 2. `path-parse` → Native `path.parse()` +```javascript +// Before +const pathParse = require('path-parse'); +const parsed = pathParse('/foo/bar.json'); + +// After +import path from 'node:path'; +const parsed = path.parse('/foo/bar.json'); +``` + +#### 3. `jsonminify` → Inline implementation or alternative +```javascript +// Simple JSON minify (remove comments and whitespace) +function jsonMinify(json) { + return JSON.stringify(JSON.parse(json)); +} +// Note: This loses comment support. May need more complex solution if comments are used. +``` + +--- + +## Conversion Challenges + +### 1. `__dirname` / `__filename` Replacements + +**Source files:** ✅ **No usage found!** + +**Test files only:** +```javascript +// t/include.js line 65 +url: `file://${__dirname}/template.json` +``` + +**ESM Replacement:** +```javascript +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +``` + +### 2. JSON Imports + +The CLI imports `package.json`: +```javascript +const pkg = require('../package.json'); +``` + +**ESM options:** +```javascript +// Option 1: Import assertion (Node 18+, experimental) +import pkg from '../package.json' with { type: 'json' }; + +// Option 2: Read and parse (safest) +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') +); + +// Option 3: createRequire (hybrid approach) +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const pkg = require('../package.json'); +``` + +### 3. Top-Level Await Opportunities + +The CLI already uses top-level await (wrapped in IIFE). Can simplify: +```javascript +// Current (wrapped IIFE) +(async () => { + const { default: yargs } = await import('yargs'); + // ... +})(); + +// ESM with top-level await +const { default: yargs } = await import('yargs'); +// ... rest of code +``` + +### 4. Dynamic Imports in Tests + +```javascript +// Current +const testFile = require(`./tests/${file}`); + +// ESM +const testFile = await import(`./tests/${file}`); +``` + +### 5. Default Export Pattern Change + +```javascript +// Current (CommonJS) +module.exports = async function (options) { ... } + +// ESM equivalent +export default async function (options) { ... } +``` + +--- + +## File-by-File Conversion Checklist + +### Conversion Order (Leaf Dependencies First) + +| Order | File | Complexity | Dependencies | Notes | +|-------|------|------------|--------------|-------| +| 1 | `/lib/internals.js` | 🟢 Easy | None | Pure functions, no requires | +| 2 | `/lib/replaceEnv.js` | 🟢 Easy | None | Pure functions, no requires | +| 3 | `/lib/utils.js` | 🟢 Easy | `assert` | Only Node builtin | +| 4 | `/lib/parselocation.js` | 🟢 Easy | `lodash` | Single lodash import | +| 5 | `/lib/request.js` | 🟢 Easy | `url`, `http`, `https` | All Node builtins | +| 6 | `/lib/schema.js` | 🟡 Medium | `js-yaml`, `lodash` | Creates YAML schema | +| 7 | `/lib/yaml.js` | 🟡 Medium | `jsonminify`, `js-yaml`, local | jsonminify needs replacement | +| 8 | `/lib/include/query.js` | 🟢 Easy | `lodash`, `jmespath` | Small file | +| 9 | `/lib/promise.js` | 🟡 Medium | `bluebird`, `lodash` | **Bluebird replacement needed** | +| 10 | `/lib/cfnclient.js` | 🟡 Medium | AWS SDK, `path`, `crypto` | Class export | +| 11 | `/lib/include/api.js` | 🔴 Complex | `aws-sdk-proxy` (v2!) | **Consider removing/rewriting** | +| 12 | `/index.js` | 🔴 Complex | Many deps | Main module, many imports | +| 13 | `/bin/cli.js` | 🟡 Medium | Local modules, yargs | CLI entry point | + +### Test File Conversion (After Source) + +| File | Notes | +|------|-------| +| `/t/include.js` | Uses `__dirname`, dynamic require | +| `/t/cli.js` | Uses child_process, dynamic require | +| `/t/replaceEnv.js` | Simple, one assertion | +| `/t/tests/extendEnv.js` | Uses lodash | +| `/t/tests/yaml.js` | Uses assert | +| `/t/tests/*.js` | Data files, minimal changes | + +--- + +## ESM Migration Plan + +### Phase 2A: Package.json Configuration + +```json +{ + "type": "module", + "exports": { + ".": { + "import": "./index.js", + "require": "./dist/index.cjs" + }, + "./lib/*": { + "import": "./lib/*.js", + "require": "./dist/lib/*.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./index.js", + "engines": { + "node": ">=20.19" + } +} +``` + +### Phase 2B: Replace Dependencies + +1. **Remove `bluebird`** - Replace with native Promise helpers +2. **Remove `path-parse`** - Use Node's built-in `path.parse()` +3. **Replace `jsonminify`** - Inline or use ESM alternative +4. **Update `lodash`** - Use `lodash-es` or individual imports +5. **Remove `lib/include/api.js`** - Unused legacy code + +### Phase 2C: Convert Files (In Order) + +1. **Add ESM polyfills file** (`/lib/esm-compat.js`): + ```javascript + // Polyfills for CJS patterns + import { fileURLToPath } from 'node:url'; + import { dirname } from 'node:path'; + + export const getDirname = (importMetaUrl) => + dirname(fileURLToPath(importMetaUrl)); + + export async function promiseProps(obj) { /* ... */ } + export async function promiseMap(arr, fn) { /* ... */ } + ``` + +2. **Convert leaf modules** (internals, utils, replaceEnv, parselocation, request) +3. **Convert intermediate modules** (schema, yaml, query, promise) +4. **Convert main module** (index.js) +5. **Convert CLI** (bin/cli.js) +6. **Convert tests** + +### Phase 2D: Dual Package Support (Optional) + +For backwards compatibility, build CJS versions: + +```bash +# Using esbuild for CJS transpilation +npx esbuild index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs +npx esbuild lib/*.js --platform=node --format=cjs --outdir=dist/lib +``` + +### Phase 2E: Bun Compatibility + +**Status:** Bun is not installed on this system. + +**Bun Considerations:** +- ✅ Bun supports ESM natively +- ✅ Bun has built-in `Bun.file()` for file reading +- ⚠️ Some Node APIs may differ slightly +- ✅ Test with `bun test` after conversion + +**Recommendation:** After ESM conversion, add Bun to CI: +```yaml +# .github/workflows/test.yml +jobs: + test-bun: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun test +``` + +--- + +## Testing Strategy + +### Between Each Conversion Step + +1. **Run existing tests** after each file conversion + ```bash + npm test + ``` + +2. **Verify CLI works** + ```bash + echo '{"Fn::Include": "t/fixtures/simple.json"}' | node bin/cli.js + ``` + +3. **Check for import errors** + ```bash + node --experimental-vm-modules -e "import('./index.js').then(m => console.log('OK', typeof m.default))" + ``` + +### Full Test Strategy + +1. **Unit tests** - Convert Mocha tests to ESM +2. **CLI tests** - Keep child_process spawning +3. **Integration tests** - Test with real templates +4. **Cross-runtime** - Test on Node 20, 22, and Bun + +### ESM Test Configuration + +Update `mocha` config for ESM: +```javascript +// mocha.config.mjs +export default { + timeout: 20000, + bail: true, + spec: ['t/**/*.js'], + // Enable experimental ESM loader + 'node-option': ['experimental-vm-modules'] +}; +``` + +--- + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Breaking changes | Medium | High | Dual CJS/ESM package | +| Bluebird removal breaks async | Low | High | Comprehensive test coverage | +| Test failures | Medium | Medium | Convert tests incrementally | +| Dependency ESM issues | Low | Medium | Pin versions, test thoroughly | + +--- + +## Conclusion + +The cfn-include codebase is **well-suited for ESM conversion**: +- No circular dependencies +- No problematic dynamic requires +- No `__dirname`/`__filename` in source (only tests) +- Most dependencies have ESM support +- Clean separation of concerns + +**Estimated effort:** 2-3 days for full conversion with testing. + +**Recommended approach:** +1. Start with leaf dependencies (lib/*.js) +2. Convert main index.js +3. Convert CLI +4. Convert tests +5. Add dual package support if needed +6. Test on Node 20/22 and Bun diff --git a/docs/PHASE3-TYPESCRIPT-ANALYSIS.md b/docs/PHASE3-TYPESCRIPT-ANALYSIS.md new file mode 100644 index 0000000..5af18c6 --- /dev/null +++ b/docs/PHASE3-TYPESCRIPT-ANALYSIS.md @@ -0,0 +1,1691 @@ +# Phase 3: TypeScript Conversion Analysis + +## Executive Summary + +This document provides a comprehensive analysis for converting `cfn-include` from JavaScript to TypeScript. The codebase presents moderate-to-high complexity due to polymorphic function signatures, deeply nested CloudFormation template structures, and runtime dynamic behavior (eval, yaml parsing, dynamic object keys). + +**Recommendation:** Full TypeScript conversion with strict mode enabled, targeting ES2022+ with NodeNext module resolution. + +--- + +## Table of Contents + +1. [Type Complexity Analysis](#1-type-complexity-analysis) +2. [Draft TypeScript Type Definitions](#2-draft-typescript-type-definitions) +3. [tsconfig.json Design](#3-tsconfigjson-design) +4. [Migration Strategy](#4-migration-strategy) +5. [Typing Challenges](#5-typing-challenges) +6. [Risk Assessment](#6-risk-assessment) +7. [Implementation Timeline](#7-implementation-timeline) + +--- + +## 1. Type Complexity Analysis + +### 1.1 Function Signature Complexity Matrix + +| File | Function | Complexity | Challenge Level | Notes | +|------|----------|------------|-----------------|-------| +| `index.js` | `module.exports` (main) | High | ⚠️⚠️⚠️ | Polymorphic options, returns `Promise` | +| `index.js` | `recurse` | Very High | ⚠️⚠️⚠️⚠️ | 30+ Fn:: handlers, recursive, scope mutations | +| `index.js` | `fnInclude` | High | ⚠️⚠️⚠️ | Protocol-aware file loading, query parsing | +| `index.js` | `handleIncludeBody` | Medium | ⚠️⚠️ | Type-based template processing | +| `index.js` | `findAndReplace` | Medium | ⚠️⚠️ | Recursive object/array traversal with mutation | +| `index.js` | `interpolate` | Low | ⚠️ | String template interpolation | +| `lib/schema.js` | YAML type constructors | High | ⚠️⚠️⚠️ | Custom YAML schema with 60+ tag definitions | +| `lib/internals.js` | `buildResourceArn` | Medium | ⚠️⚠️ | Resource-type-specific ARN construction | +| `lib/promise.js` | `mapWhatever` | Medium | ⚠️⚠️ | Generic over array/object iteration | +| `lib/replaceEnv.js` | `replaceEnv` | Low | ⚠️ | String replacement with optional chaining | +| `lib/parselocation.js` | `parseLocation` | Low | ⚠️ | Regex-based URL parsing | + +### 1.2 Polymorphic Functions Analysis + +#### 1.2.1 Main Export Function (`include`) + +```typescript +// Current JS signature (implicit) +function include(options) -> Promise + +// The options parameter has multiple valid shapes: +{ + template?: TemplateDocument | undefined, // Optional: inline template + url: string, // Required: file/s3/http URL + doEnv?: boolean, // Enable env substitution + doEval?: boolean, // Enable Fn::Eval (dangerous) + inject?: Record, // Variable injection + doLog?: boolean, // Debug logging + refNowIgnores?: string[], // Skip these refs + refNowIgnoreMissing?: boolean, // Don't fail on missing refs + rootTemplate?: TemplateDocument, // For Fn::RefNow resolution + scope?: Scope // Internal: scope variables +} +``` + +**Complexity factors:** +- `template` can be omitted if `url` points to a loadable file +- Return type depends on template content (could be object, array, string, number) +- Options flow through recursive calls with additions + +#### 1.2.2 The `recurse` Function (Most Complex) + +```typescript +// Handles 30+ Fn:: intrinsic functions +// Each branch has unique input/output shapes: + +'Fn::Map' -> [list, placeholders, body] | [list, body] +'Fn::Include' -> string | [location, query, parser?] | IncludeOptions +'Fn::Eval' -> { state, script, inject?, doLog? } +'Fn::IfEval' -> { truthy, falsy, evalCond, inject?, doLog? } +'Fn::RefNow' -> string | { Ref: string, returnType?: 'arn' | 'name' } +'Fn::SortBy' -> { list, iteratees } +'Fn::Without' -> [list, withouts] | { list, withouts } +'Fn::Omit' -> [object, omits] | { object, omits } +// ... 20+ more +``` + +**The union type explosion problem:** +```typescript +type FnMapArgs = + | [unknown[], unknown] // [list, body] + | [unknown[], string, unknown] // [list, placeholder, body] + | [unknown[], [string, string?, string?], unknown]; // With index/size + +type FnIncludeArgs = + | string + | [string, string?, string?] + | IncludeOptions; +``` + +#### 1.2.3 Scope Mutation Patterns + +```javascript +// Current pattern (mutation-based) +scope = _.clone(scope); +scope[placeholder] = replace; +if (hasindex) scope[idx] = key; +``` + +This requires careful typing: +```typescript +interface Scope { + [key: string]: unknown; + _?: unknown; // Default placeholder +} +``` + +### 1.3 Nested Structure Analysis + +#### 1.3.1 CloudFormation Template Structure + +``` +TemplateDocument (max depth observed: 12+ levels) +├── AWSTemplateFormatVersion +├── Description +├── Metadata +│ └── CfnInclude +│ ├── GitCommit +│ └── BuildDate +├── Parameters +│ └── [ParamName] +│ ├── Type +│ ├── Default +│ └── Description +├── Mappings +│ └── [MapName] +│ └── [TopLevelKey] +│ └── [SecondLevelKey]: value +├── Conditions +│ └── [ConditionName]: IntrinsicFunction +├── Resources +│ └── [LogicalId] +│ ├── Type +│ ├── Condition? +│ ├── DependsOn? +│ ├── Properties +│ │ └── (resource-specific, deeply nested) +│ └── Metadata? +├── Outputs +│ └── [OutputName] +│ ├── Value +│ ├── Condition? +│ ├── Export? +│ │ └── Name +│ └── Description? +└── Transform? +``` + +#### 1.3.2 Intrinsic Function Nesting + +CloudFormation intrinsic functions can nest arbitrarily deep: + +```yaml +# Observed nesting patterns +Value: !Sub + - "arn:aws:s3:::${Bucket}/*" + - Bucket: !If + - CreateBucket + - !Ref NewBucket + - !FindInMap + - Config + - !Ref Environment + - BucketName +``` + +This requires recursive type definitions: + +```typescript +type IntrinsicFunction = + | FnSub + | FnRef + | FnIf + | FnFindInMap + | FnGetAtt + | FnJoin + | FnSelect + | FnBase64 + | FnCidr + | FnAnd + | FnEquals + | FnNot + | FnOr + | FnImportValue + | FnSplit + | FnGetAZs + // cfn-include custom + | FnInclude + | FnMap + | FnMerge + | FnDeepMerge + | FnFlatten + | FnEval + | FnIfEval + | FnRefNow + | FnSubNow + | FnJoinNow + // ... etc + +type TemplateValue = + | string + | number + | boolean + | null + | TemplateValue[] + | { [key: string]: TemplateValue } + | IntrinsicFunction; +``` + +--- + +## 2. Draft TypeScript Type Definitions + +### 2.1 Core Types + +```typescript +// types/core.ts + +/** + * AWS Pseudo Parameters available at runtime + */ +export interface AwsPseudoParameters { + 'AWS::AccountId': string; + 'AWS::Partition': string; + 'AWS::Region': string; + 'AWS::StackId': string; + 'AWS::StackName': string; + 'AWS::URLSuffix': string; + 'AWS::NotificationARNs': string; +} + +/** + * Parsed location from URL/path + */ +export interface ParsedLocation { + /** Protocol: 'file', 's3', 'http', 'https' */ + protocol: string | undefined; + /** Host portion of URL (bucket name for S3, hostname for HTTP) */ + host: string; + /** Path portion of URL */ + path: string | undefined; + /** Whether the path is relative */ + relative: boolean; + /** Original raw location string */ + raw: string; +} + +/** + * Variable scope for Fn::Map template substitutions + * Keys are placeholder names, values are substitution values + */ +export interface Scope { + [key: string]: unknown; + /** Default placeholder when using shorthand Fn::Map */ + _?: unknown; +} + +/** + * Options for the main include function + */ +export interface IncludeOptions { + /** Pre-parsed template object (optional if url is provided) */ + template?: TemplateDocument; + /** Location URL: file://, s3://, http://, https:// */ + url: string; + /** Enable environment variable substitution from process.env */ + doEnv?: boolean; + /** Enable Fn::Eval and Fn::IfEval (security risk) */ + doEval?: boolean; + /** Variables to inject for ${KEY} substitution */ + inject?: Record; + /** Enable debug logging */ + doLog?: boolean; + /** Logical resource IDs to skip in Fn::RefNow resolution */ + refNowIgnores?: string[]; + /** If true, return Ref syntax instead of throwing for unresolvable Fn::RefNow */ + refNowIgnoreMissing?: boolean; + /** Root template for resource lookups in Fn::RefNow */ + rootTemplate?: TemplateDocument; + /** Internal: current variable scope */ + scope?: Scope; +} + +/** + * Internal options passed through recurse calls + */ +export interface RecurseOptions extends IncludeOptions { + /** Parsed base location */ + base: ParsedLocation; + /** Current template/value being processed */ + cft: TemplateValue; + /** Current property key (used for Fn::RefNow return type inference) */ + key?: string; + /** Calling function name (for debug logging) */ + caller?: string; +} +``` + +### 2.2 CloudFormation Template Types + +```typescript +// types/template.ts + +/** + * CloudFormation template document + */ +export interface TemplateDocument { + AWSTemplateFormatVersion?: '2010-09-09'; + Description?: string; + Metadata?: TemplateMetadata; + Parameters?: Record; + Mappings?: Record>>; + Conditions?: Record; + Transform?: string | string[]; + Resources?: Record; + Outputs?: Record; +} + +/** + * Template metadata section + */ +export interface TemplateMetadata { + CfnInclude?: { + GitCommit?: string; + BuildDate?: string; + }; + [key: string]: unknown; +} + +/** + * CloudFormation parameter definition + */ +export interface TemplateParameter { + Type: ParameterType; + Default?: TemplateValue; + Description?: string; + AllowedPattern?: string; + AllowedValues?: string[]; + ConstraintDescription?: string; + MaxLength?: number; + MaxValue?: number; + MinLength?: number; + MinValue?: number; + NoEcho?: boolean; +} + +export type ParameterType = + | 'String' + | 'Number' + | 'List' + | 'CommaDelimitedList' + | 'AWS::SSM::Parameter::Name' + | 'AWS::SSM::Parameter::Value' + | `AWS::SSM::Parameter::Value>` + | `AWS::EC2::${string}::Id` + | `List`; + +/** + * CloudFormation resource definition + */ +export interface Resource { + Type: string; + Condition?: string; + DependsOn?: string | string[]; + DeletionPolicy?: 'Delete' | 'Retain' | 'Snapshot'; + UpdatePolicy?: Record; + UpdateReplacePolicy?: 'Delete' | 'Retain' | 'Snapshot'; + CreationPolicy?: Record; + Metadata?: Record; + Properties?: Record; +} + +/** + * CloudFormation output definition + */ +export interface Output { + Value: TemplateValue; + Description?: string; + Condition?: string; + Export?: { + Name: TemplateValue; + }; +} + +/** + * Any valid template value (recursive) + */ +export type TemplateValue = + | string + | number + | boolean + | null + | undefined + | TemplateValue[] + | TemplateObject + | IntrinsicFunction; + +/** + * Plain template object (not an intrinsic function) + */ +export interface TemplateObject { + [key: string]: TemplateValue; +} +``` + +### 2.3 Intrinsic Function Types (AWS Standard) + +```typescript +// types/intrinsics-aws.ts + +/** + * AWS CloudFormation intrinsic functions + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html + */ + +/** Fn::Base64 - Encode string to Base64 */ +export interface FnBase64 { + 'Fn::Base64': TemplateValue; +} + +/** Fn::Cidr - Generate CIDR blocks */ +export interface FnCidr { + 'Fn::Cidr': [TemplateValue, number | TemplateValue, number | TemplateValue]; +} + +/** Fn::FindInMap - Lookup value in Mappings */ +export interface FnFindInMap { + 'Fn::FindInMap': [string | TemplateValue, string | TemplateValue, string | TemplateValue]; +} + +/** Fn::GetAtt - Get resource attribute */ +export interface FnGetAtt { + 'Fn::GetAtt': [string, string] | string; // string form: "LogicalName.Attribute" +} + +/** Fn::GetAZs - Get availability zones */ +export interface FnGetAZs { + 'Fn::GetAZs': string | TemplateValue; +} + +/** Fn::ImportValue - Import exported value */ +export interface FnImportValue { + 'Fn::ImportValue': TemplateValue; +} + +/** Fn::Join - Join array with delimiter */ +export interface FnJoin { + 'Fn::Join': [string, TemplateValue[]]; +} + +/** Fn::Select - Select item from array by index */ +export interface FnSelect { + 'Fn::Select': [number | TemplateValue, TemplateValue[]]; +} + +/** Fn::Split - Split string into array */ +export interface FnSplit { + 'Fn::Split': [string, TemplateValue]; +} + +/** Fn::Sub - Substitute variables in string */ +export interface FnSub { + 'Fn::Sub': string | [string, Record]; +} + +/** Ref - Reference parameter or resource */ +export interface Ref { + Ref: string; +} + +/** Condition - Reference a condition */ +export interface Condition { + Condition: string; +} + +/** Fn::And - Logical AND */ +export interface FnAnd { + 'Fn::And': IntrinsicFunction[]; +} + +/** Fn::Equals - Equality comparison */ +export interface FnEquals { + 'Fn::Equals': [TemplateValue, TemplateValue]; +} + +/** Fn::If - Conditional value */ +export interface FnIf { + 'Fn::If': [string, TemplateValue, TemplateValue]; +} + +/** Fn::Not - Logical NOT */ +export interface FnNot { + 'Fn::Not': [IntrinsicFunction]; +} + +/** Fn::Or - Logical OR */ +export interface FnOr { + 'Fn::Or': IntrinsicFunction[]; +} + +/** Fn::GetParam - CodePipeline parameter */ +export interface FnGetParam { + 'Fn::GetParam': [string, string, string]; +} + +/** Union of all AWS intrinsic functions */ +export type AwsIntrinsicFunction = + | FnBase64 + | FnCidr + | FnFindInMap + | FnGetAtt + | FnGetAZs + | FnImportValue + | FnJoin + | FnSelect + | FnSplit + | FnSub + | Ref + | Condition + | FnAnd + | FnEquals + | FnIf + | FnNot + | FnOr + | FnGetParam; +``` + +### 2.4 cfn-include Custom Function Types + +```typescript +// types/intrinsics-cfn-include.ts + +/** + * cfn-include custom intrinsic functions + */ + +/** Fn::Include - Include external template */ +export interface FnInclude { + 'Fn::Include': FnIncludeArgs; +} + +export type FnIncludeArgs = + | string // Simple path + | [string, string?, string?] // [location, query?, parser?] + | FnIncludeOptions; + +export interface FnIncludeOptions { + /** File/S3/HTTP location */ + location: string; + /** Query to extract from template (lodash path or jmespath) */ + query?: string | TemplateValue; + /** Parser: 'lodash' or 'jmespath' */ + parser?: 'lodash' | 'jmespath'; + /** Type of content: 'json', 'string', 'literal' */ + type?: 'json' | 'string' | 'literal'; + /** Context for literal interpolation */ + context?: Record; + /** Variables to inject */ + inject?: Record; + /** Treat as glob pattern */ + isGlob?: boolean; + /** Ignore missing ${VAR} references */ + ignoreMissingVar?: boolean; + /** Ignore missing files */ + ignoreMissingFile?: boolean; + /** Debug logging */ + doLog?: boolean; +} + +/** Fn::Map - Map over array/object */ +export interface FnMap { + 'Fn::Map': FnMapArgs; +} + +export type FnMapArgs = + | [TemplateValue[], TemplateValue] // [list, body] + | [TemplateValue[], string, TemplateValue] // [list, placeholder, body] + | [TemplateValue[], [string], TemplateValue] // [list, [placeholder], body] + | [TemplateValue[], [string, string], TemplateValue] // [list, [placeholder, idx], body] + | [TemplateValue[], [string, string, string], TemplateValue]; // [list, [placeholder, idx, size], body] + +/** Fn::Length - Get array length */ +export interface FnLength { + 'Fn::Length': TemplateValue | TemplateValue[]; +} + +/** Fn::Flatten - Flatten array one level */ +export interface FnFlatten { + 'Fn::Flatten': TemplateValue; +} + +/** Fn::FlattenDeep - Flatten array recursively */ +export interface FnFlattenDeep { + 'Fn::FlattenDeep': TemplateValue; +} + +/** Fn::Uniq - Remove duplicates from array */ +export interface FnUniq { + 'Fn::Uniq': TemplateValue; +} + +/** Fn::Compact - Remove falsy values from array */ +export interface FnCompact { + 'Fn::Compact': TemplateValue; +} + +/** Fn::Concat - Concatenate arrays */ +export interface FnConcat { + 'Fn::Concat': TemplateValue[]; +} + +/** Fn::Sort - Sort array */ +export interface FnSort { + 'Fn::Sort': TemplateValue; +} + +/** Fn::SortedUniq - Sort and remove duplicates */ +export interface FnSortedUniq { + 'Fn::SortedUniq': TemplateValue; +} + +/** Fn::SortBy - Sort by iteratees */ +export interface FnSortBy { + 'Fn::SortBy': { + list: TemplateValue[]; + iteratees: string | string[]; + }; +} + +/** Fn::SortObject - Sort object keys */ +export interface FnSortObject { + 'Fn::SortObject': FnSortObjectArgs; +} + +export type FnSortObjectArgs = + | { object: TemplateObject; options?: SortObjectOptions } + | TemplateObject; // Object to sort (no options) + +export interface SortObjectOptions { + deep?: boolean; + sortWith?: (a: string, b: string) => number; +} + +/** Fn::Without - Remove values from array */ +export interface FnWithout { + 'Fn::Without': [TemplateValue[], TemplateValue[]] | { list: TemplateValue[]; withouts: TemplateValue[] }; +} + +/** Fn::Omit - Omit keys from object */ +export interface FnOmit { + 'Fn::Omit': [TemplateObject, string[]] | { object: TemplateObject; omits: string[] }; +} + +/** Fn::OmitEmpty - Omit falsy values from object */ +export interface FnOmitEmpty { + 'Fn::OmitEmpty': TemplateObject; +} + +/** Fn::Merge - Shallow merge objects */ +export interface FnMerge { + 'Fn::Merge': TemplateValue[]; +} + +/** Fn::DeepMerge - Deep merge objects */ +export interface FnDeepMerge { + 'Fn::DeepMerge': TemplateValue[]; +} + +/** Fn::ObjectKeys - Get object keys */ +export interface FnObjectKeys { + 'Fn::ObjectKeys': TemplateValue; +} + +/** Fn::ObjectValues - Get object values */ +export interface FnObjectValues { + 'Fn::ObjectValues': TemplateValue; +} + +/** Fn::Stringify - JSON stringify */ +export interface FnStringify { + 'Fn::Stringify': TemplateValue; +} + +/** Fn::StringSplit - Split string */ +export interface FnStringSplit { + 'Fn::StringSplit': { + string: string; + separator?: string; + doLog?: boolean; + }; +} + +/** Fn::GetEnv - Get environment variable */ +export interface FnGetEnv { + 'Fn::GetEnv': string | [string, string]; // name or [name, default] +} + +/** Fn::UpperCamelCase - Convert to UpperCamelCase */ +export interface FnUpperCamelCase { + 'Fn::UpperCamelCase': string; +} + +/** Fn::LowerCamelCase - Convert to lowerCamelCase */ +export interface FnLowerCamelCase { + 'Fn::LowerCamelCase': string; +} + +/** Fn::Sequence - Generate numeric/char sequence */ +export interface FnSequence { + 'Fn::Sequence': [number | string, number | string] | [number | string, number | string, number]; +} + +/** Fn::Outputs - Generate CloudFormation Outputs with exports */ +export interface FnOutputs { + 'Fn::Outputs': Record; +} + +/** Fn::Filenames - Get filenames matching glob */ +export interface FnFilenames { + 'Fn::Filenames': string | { + location: string; + omitExtension?: boolean; + doLog?: boolean; + }; +} + +/** Fn::Eval - Execute JavaScript (DANGEROUS) */ +export interface FnEval { + 'Fn::Eval': { + state?: unknown; + script: string; + inject?: Record; + doLog?: boolean; + }; +} + +/** Fn::IfEval - Conditional with JavaScript evaluation (DANGEROUS) */ +export interface FnIfEval { + 'Fn::IfEval': { + evalCond: string; + truthy?: TemplateValue; + falsy?: TemplateValue; + inject?: Record; + doLog?: boolean; + }; +} + +/** Fn::JoinNow - Join immediately (not deferred to CloudFormation) */ +export interface FnJoinNow { + 'Fn::JoinNow': [string, TemplateValue[]]; +} + +/** Fn::SubNow - Substitute immediately */ +export interface FnSubNow { + 'Fn::SubNow': string | [string, Record]; +} + +/** Fn::RefNow - Resolve reference immediately */ +export interface FnRefNow { + 'Fn::RefNow': string | FnRefNowOptions; +} + +export interface FnRefNowOptions { + Ref?: string; + ref?: string; + returnType?: 'arn' | 'name'; +} + +/** Fn::ApplyTags - Apply tags to taggable resources */ +export interface FnApplyTags { + 'Fn::ApplyTags': { + tags?: Tag[]; + Tags?: Tag[]; + resources: Record; + }; +} + +export interface Tag { + Key: string; + Value: TemplateValue; +} + +/** Union of all cfn-include custom functions */ +export type CfnIncludeIntrinsicFunction = + | FnInclude + | FnMap + | FnLength + | FnFlatten + | FnFlattenDeep + | FnUniq + | FnCompact + | FnConcat + | FnSort + | FnSortedUniq + | FnSortBy + | FnSortObject + | FnWithout + | FnOmit + | FnOmitEmpty + | FnMerge + | FnDeepMerge + | FnObjectKeys + | FnObjectValues + | FnStringify + | FnStringSplit + | FnGetEnv + | FnUpperCamelCase + | FnLowerCamelCase + | FnSequence + | FnOutputs + | FnFilenames + | FnEval + | FnIfEval + | FnJoinNow + | FnSubNow + | FnRefNow + | FnApplyTags; + +/** All intrinsic functions (AWS + cfn-include) */ +export type IntrinsicFunction = AwsIntrinsicFunction | CfnIncludeIntrinsicFunction; +``` + +### 2.5 Utility Types + +```typescript +// types/utils.ts + +/** + * Type guards for intrinsic function detection + */ +export function isFnInclude(value: unknown): value is FnInclude { + return isPlainObject(value) && 'Fn::Include' in value; +} + +export function isFnMap(value: unknown): value is FnMap { + return isPlainObject(value) && 'Fn::Map' in value; +} + +export function isFnRefNow(value: unknown): value is FnRefNow { + return isPlainObject(value) && 'Fn::RefNow' in value; +} + +export function isIntrinsicFunction(value: unknown): value is IntrinsicFunction { + if (!isPlainObject(value)) return false; + const keys = Object.keys(value); + if (keys.length !== 1) return false; + return keys[0].startsWith('Fn::') || keys[0] === 'Ref' || keys[0] === 'Condition'; +} + +export function isOurExplicitFunction(key: string): boolean { + const awsFunctions = [ + 'Fn::Base64', 'Fn::FindInMap', 'Fn::GetAtt', 'Fn::GetAZs', + 'Fn::ImportValue', 'Fn::Join', 'Fn::Select', 'Fn::Split', + 'Fn::Sub', 'Fn::Cidr', 'Fn::GetParam', 'Fn::And', 'Fn::Equals', + 'Fn::If', 'Fn::Not', 'Fn::Or', 'Ref', 'Condition' + ]; + return /^Fn::/.test(key) && !awsFunctions.includes(key); +} + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +/** + * Query parser types + */ +export type QueryParser = 'lodash' | 'jmespath' | 'default'; + +export interface QueryParsers { + lodash: (obj: T, path: string) => unknown; + jmespath: (obj: T, query: string) => unknown; + default: (obj: T, query: string) => unknown; +} + +/** + * CLI options + */ +export interface CliOptions { + path?: string; + minimize?: boolean; + metadata?: boolean; + validate?: boolean; + yaml?: boolean; + lineWidth?: number; + bucket?: string; + context?: string; + prefix?: string; + enable?: string; + inject?: Record; + doLog?: boolean; + 'ref-now-ignore-missing'?: boolean; + 'ref-now-ignores'?: string; + version?: boolean; +} + +/** + * CloudFormation client options + */ +export interface CfnClientOptions { + region?: string; + bucket?: string; + prefix?: string; +} +``` + +### 2.6 YAML Schema Types + +```typescript +// types/schema.ts + +import type { Type, Schema } from 'js-yaml'; + +export interface YamlTagDefinition { + short: string; + full: string; + type: 'scalar' | 'sequence' | 'mapping'; + dotSyntax?: boolean; +} + +/** + * Custom YAML type that constructs CloudFormation intrinsic functions + */ +export function createYamlType(definition: YamlTagDefinition): Type; + +/** + * Extended YAML schema with CloudFormation tags + */ +export const cfnSchema: Schema; + +/** + * List of AWS intrinsic function names (bang syntax) + */ +export const BANG_AMAZON_FUNCS: readonly string[]; + +/** + * List of AWS intrinsic function names (explicit syntax) + */ +export const EXPLICIT_AMAZON_FUNCS: readonly string[]; +``` + +--- + +## 3. tsconfig.json Design + +### 3.1 Recommended Configuration + +```jsonc +// tsconfig.json +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + // Target & Module + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + + // Strict Type Checking (ALL ENABLED) + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + + // Additional Checks + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": false, // Too restrictive for CFN templates + + // Interop + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + + // Output + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + + // Type Checking + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "t/**/*" + ] +} +``` + +### 3.2 Configuration Rationale + +| Option | Value | Rationale | +|--------|-------|-----------| +| `target: ES2022` | ES2022 | Node 20+ supports ES2022 features, enables `Array.at()`, `Object.hasOwn()` | +| `module: NodeNext` | NodeNext | Native ESM with `.js` extensions, aligns with Phase 2 ESM migration | +| `strict: true` | true | Catches type errors early, essential for complex recursive functions | +| `noUncheckedIndexedAccess` | true | Critical for template object access safety | +| `exactOptionalPropertyTypes` | true | Prevents `undefined` where only omission is intended | +| `useUnknownInCatchVariables` | true | Safer error handling in async code | +| `verbatimModuleSyntax` | true | Explicit `import type` for type-only imports | +| `declaration: true` | true | Generates `.d.ts` for library consumers | + +### 3.3 Development vs Production Configs + +```jsonc +// tsconfig.dev.json (extends base) +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo" + } +} +``` + +```jsonc +// tsconfig.build.json (for release) +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "removeComments": true, + "stripInternal": true + }, + "exclude": [ + "**/*.test.ts", + "**/__tests__/**" + ] +} +``` + +--- + +## 4. Migration Strategy + +### 4.1 Recommended Approach: Full Conversion + +**Decision: Full TypeScript conversion (not gradual `allowJs`)** + +**Rationale:** +1. Codebase is small (10 source files, ~1,500 lines) +2. Types are highly interconnected (gradual conversion creates friction) +3. `recurse()` function requires complete type coverage to be useful +4. Benefits of strict typing only realized with full coverage + +### 4.2 File Conversion Order + +Ordered by dependency depth (leaves first): + +``` +Phase 1: Leaf modules (no internal dependencies) +├── lib/utils.ts (10 min) - Simple string utilities +├── lib/parselocation.ts (15 min) - URL parsing +├── lib/replaceEnv.ts (20 min) - String substitution +├── lib/request.ts (20 min) - HTTP client +└── lib/promise.ts (30 min) - Bluebird wrapper + +Phase 2: Core utilities +├── lib/internals.ts (45 min) - AWS pseudo-params, ARN builder +├── lib/schema.ts (60 min) - YAML tag definitions +├── lib/yaml.ts (20 min) - YAML load/dump +└── lib/include/query.ts (20 min) - Query parsers + +Phase 3: Main modules +├── lib/cfnclient.ts (30 min) - CloudFormation client +└── index.ts (3-4 hrs) - Main include logic + +Phase 4: CLI +└── bin/cli.ts (1 hr) - Command-line interface + +Estimated total: 8-10 hours +``` + +### 4.3 Testing Strategy + +#### 4.3.1 Type Testing + +```typescript +// types/__tests__/intrinsics.test-d.ts +import { expectType, expectError } from 'tsd'; +import type { FnMap, FnInclude, TemplateValue } from '../intrinsics'; + +// Valid Fn::Map +expectType({ 'Fn::Map': [[1, 2, 3], { Value: '_' }] }); +expectType({ 'Fn::Map': [[1, 2, 3], 'x', { Value: '${x}' }] }); + +// Invalid Fn::Map (wrong shape) +expectError({ 'Fn::Map': 'invalid' }); +expectError({ 'Fn::Map': [] }); +``` + +#### 4.3.2 Runtime Testing (Existing Mocha Tests) + +```typescript +// t/include.test.ts +import assert from 'node:assert/strict'; +import { describe, it, beforeEach } from 'mocha'; +import include from '../src/index.js'; +import type { IncludeOptions, TemplateDocument } from '../src/types/index.js'; + +describe('include', () => { + it('resolves Fn::Map with typed options', async () => { + const options: IncludeOptions = { + template: { + 'Fn::Map': [[1, 2], { Value: '_' }] + }, + url: 'file:///template.json' + }; + + const result = await include(options); + assert.deepEqual(result, [{ Value: 1 }, { Value: 2 }]); + }); +}); +``` + +#### 4.3.3 Test Coverage Requirements + +| Category | Target | Notes | +|----------|--------|-------| +| Type definitions | 100% | All exported types have test-d.ts coverage | +| Core functions | 95% | `recurse`, `fnInclude`, `handleIncludeBody` | +| Edge cases | 90% | Null handling, empty arrays, missing keys | +| Integration | 85% | File/S3/HTTP loading paths | + +### 4.4 Pre-Migration Checklist + +```markdown +- [ ] Install TypeScript 5.4+ and development dependencies +- [ ] Create `src/` directory structure +- [ ] Copy type definition files to `src/types/` +- [ ] Set up `tsconfig.json` configurations +- [ ] Configure ESLint for TypeScript (`@typescript-eslint/*`) +- [ ] Set up `tsd` for type testing +- [ ] Create `build` and `typecheck` npm scripts +- [ ] Update `.gitignore` for `dist/`, `.tsbuildinfo` +``` + +### 4.5 Post-Migration Checklist + +```markdown +- [ ] All existing tests pass with TypeScript source +- [ ] `npm run typecheck` passes with zero errors +- [ ] `npm run build` produces valid JavaScript output +- [ ] Generated `.d.ts` files are valid and complete +- [ ] Package.json exports updated for ESM + types +- [ ] Downstream CDK integration tested (Phase 4 blocker) +``` + +--- + +## 5. Typing Challenges + +### 5.1 eval() and Dynamic Code Execution + +**Challenge:** `Fn::Eval` and `Fn::IfEval` use JavaScript `eval()` which cannot be statically typed. + +```javascript +// Current code +return eval(script); +``` + +**TypeScript Solution:** + +```typescript +// types/eval.ts +export interface EvalContext { + state: unknown; + [key: string]: unknown; +} + +// Implementation +function executeFnEval(args: FnEval['Fn::Eval']): unknown { + const { state, script, inject, doLog } = args; + + // Create execution context + const context: EvalContext = { state, ...inject }; + + // Execute in sandboxed context + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const fn = new Function('ctx', `with(ctx) { return (${script}); }`); + return fn(context) as unknown; +} +``` + +**Type safety measures:** +1. Return type is always `unknown` (caller must validate) +2. ESLint rule suppression with explicit comment +3. Runtime validation of result before use + +### 5.2 Dynamic Object Keys + +**Challenge:** CloudFormation templates have arbitrary string keys. + +```typescript +// Problem: TypeScript doesn't know what keys exist +const resources = template.Resources; +const myRole = resources['MyRole']; // Type: TemplateValue | undefined +``` + +**Solution: Type narrowing utilities** + +```typescript +// lib/type-guards.ts +export function getResource( + template: TemplateDocument, + logicalId: string +): Resource | undefined { + return template.Resources?.[logicalId] as Resource | undefined; +} + +export function hasResource( + template: TemplateDocument, + logicalId: string +): boolean { + return logicalId in (template.Resources ?? {}); +} + +// Usage in Fn::RefNow +if (rootTemplate && hasResource(rootTemplate, refName)) { + const resource = getResource(rootTemplate, refName)!; + // resource is typed as Resource +} +``` + +### 5.3 Bluebird Promise.props + +**Challenge:** Bluebird's `Promise.props` maps over object values returning promises. + +```javascript +// Current usage +return Promise.props( + _.mapValues(cft, (template, key) => recurse({ ... })) +); +``` + +**TypeScript Solution:** + +```typescript +// lib/promise.ts +import Bluebird from 'bluebird'; + +/** + * Resolve all promise values in an object + */ +export async function promiseProps>( + obj: { [K in keyof T]: T[K] | Promise } +): Promise { + // Bluebird.props is already properly typed + return Bluebird.props(obj) as Promise; +} + +// Alternative: Native Promise implementation +export async function promisePropsNative>( + obj: { [K in keyof T]: T[K] | Promise } +): Promise { + const entries = Object.entries(obj); + const values = await Promise.all(entries.map(([, v]) => v)); + return Object.fromEntries( + entries.map(([k], i) => [k, values[i]]) + ) as T; +} +``` + +**Migration path:** +1. Short-term: Use Bluebird's TypeScript definitions (`@types/bluebird`) +2. Long-term: Replace with native `Promise.all` + object reconstruction + +### 5.4 yaml.load Type Safety + +**Challenge:** `js-yaml`'s `load()` returns `unknown`, losing all type information. + +```javascript +// Current code +const json = yaml.load(res, { schema: yamlSchema }); +``` + +**Solution: Typed wrapper with validation** + +```typescript +// lib/yaml.ts +import jsYaml from 'js-yaml'; +import { cfnSchema } from './schema.js'; +import type { TemplateDocument, TemplateValue } from '../types/index.js'; + +export function loadTemplate(content: string): TemplateDocument { + const result = jsYaml.load(content, { schema: cfnSchema }); + + // Runtime validation + if (!isValidTemplate(result)) { + throw new TypeError('Invalid CloudFormation template structure'); + } + + return result; +} + +export function loadValue(content: string): TemplateValue { + return jsYaml.load(content, { schema: cfnSchema }) as TemplateValue; +} + +function isValidTemplate(value: unknown): value is TemplateDocument { + if (typeof value !== 'object' || value === null) return false; + + // Minimal validation + const obj = value as Record; + if ('AWSTemplateFormatVersion' in obj) { + return obj.AWSTemplateFormatVersion === '2010-09-09'; + } + // Allow templates without version + return true; +} +``` + +### 5.5 Recursive Type Definitions + +**Challenge:** Template values can nest infinitely. + +```typescript +// Naive approach causes TypeScript error: +// Type alias 'TemplateValue' circularly references itself +type TemplateValue = string | number | { [key: string]: TemplateValue }; +``` + +**Solution: Interface for object shapes** + +```typescript +// This works because interfaces allow self-reference +export interface TemplateObject { + [key: string]: TemplateValue; +} + +export type TemplateValue = + | string + | number + | boolean + | null + | undefined + | TemplateValue[] + | TemplateObject + | IntrinsicFunction; +``` + +### 5.6 Function Overloads for Polymorphic APIs + +**Challenge:** Functions accept multiple input shapes. + +```typescript +// fnIncludeOpts accepts 3 different input types +function fnIncludeOpts(cft, opts) { + if (_.isPlainObject(cft)) { ... } + else if (_.isArray(cft)) { ... } + else { /* string */ ... } +} +``` + +**Solution: Function overloads** + +```typescript +// Overload signatures +function fnIncludeOpts(cft: FnIncludeOptions, opts: RecurseOptions): FnIncludeOptions; +function fnIncludeOpts(cft: [string, string?, string?], opts: RecurseOptions): FnIncludeOptions; +function fnIncludeOpts(cft: string, opts: RecurseOptions): FnIncludeOptions; + +// Implementation +function fnIncludeOpts( + cft: FnIncludeArgs, + opts: RecurseOptions +): FnIncludeOptions { + if (isPlainObject(cft)) { + return { ...cft, ...opts } as FnIncludeOptions; + } + if (Array.isArray(cft)) { + const [location, query, parser = 'lodash'] = cft; + return { location, query, parser, ...opts }; + } + // String + const splits = cft.split('|'); + if (splits.length > 1) { + const [location, query, parser = 'lodash'] = splits; + return { location, query, parser, ...opts }; + } + return { location: cft, ...opts }; +} +``` + +--- + +## 6. Risk Assessment + +### 6.1 Risk Matrix + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Type definition errors | Medium | High | Comprehensive type tests with `tsd` | +| Runtime behavior changes | Low | Critical | 100% existing test coverage before migration | +| Bluebird compatibility | Low | Medium | Use `@types/bluebird`, consider native Promise migration | +| Build complexity | Medium | Low | Well-documented npm scripts | +| Performance regression | Low | Low | Benchmark before/after (Phase 1 benchmarks) | +| Third-party type issues | Medium | Medium | Pin dependency versions, override types if needed | + +### 6.2 Breaking Changes + +**None expected.** TypeScript compilation should produce semantically identical JavaScript. + +**Potential edge cases:** +1. Error message strings may differ slightly due to TypeScript's stricter checks +2. Stack traces will reference `.ts` files (source maps required for debugging) +3. Package.json exports must be updated correctly + +### 6.3 Rollback Plan + +```bash +# If TypeScript conversion causes issues +git checkout main -- src/ lib/ index.js bin/cli.js +npm run test # Verify original code works +``` + +--- + +## 7. Implementation Timeline + +### 7.1 Gantt Chart (Suggested) + +``` +Week 1: Foundation +├── Day 1-2: Set up TypeScript, create type definitions +├── Day 3-4: Convert leaf modules (utils, parselocation, replaceEnv) +└── Day 5: Convert request.ts, promise.ts + +Week 2: Core Modules +├── Day 1-2: Convert internals.ts, schema.ts +├── Day 3: Convert yaml.ts, query.ts +└── Day 4-5: Start index.ts conversion (recurse function) + +Week 3: Main Logic + CLI +├── Day 1-3: Complete index.ts conversion +├── Day 4: Convert cli.ts +└── Day 5: Integration testing + +Week 4: Finalization +├── Day 1-2: Type testing with tsd +├── Day 3: Documentation updates +├── Day 4: Performance benchmarks +└── Day 5: Release preparation +``` + +### 7.2 Dependencies on Other Phases + +| Dependency | Direction | Notes | +|------------|-----------|-------| +| Phase 2 (ESM) | Before TS | TypeScript targets ESM output | +| Phase 4 (CDK) | After TS | CDK integration needs `.d.ts` files | +| Phase 1 (Perf) | Before TS | Establish baseline benchmarks | + +--- + +## Appendix A: Complete Type Index + +```typescript +// types/index.ts - Main export file +export type { + // Core + AwsPseudoParameters, + ParsedLocation, + Scope, + IncludeOptions, + RecurseOptions, + + // Template + TemplateDocument, + TemplateMetadata, + TemplateParameter, + ParameterType, + Resource, + Output, + TemplateValue, + TemplateObject, + + // AWS Intrinsics + FnBase64, + FnCidr, + FnFindInMap, + FnGetAtt, + FnGetAZs, + FnImportValue, + FnJoin, + FnSelect, + FnSplit, + FnSub, + Ref, + Condition, + FnAnd, + FnEquals, + FnIf, + FnNot, + FnOr, + FnGetParam, + AwsIntrinsicFunction, + + // cfn-include Intrinsics + FnInclude, + FnIncludeArgs, + FnIncludeOptions, + FnMap, + FnMapArgs, + FnLength, + FnFlatten, + FnFlattenDeep, + FnUniq, + FnCompact, + FnConcat, + FnSort, + FnSortedUniq, + FnSortBy, + FnSortObject, + SortObjectOptions, + FnWithout, + FnOmit, + FnOmitEmpty, + FnMerge, + FnDeepMerge, + FnObjectKeys, + FnObjectValues, + FnStringify, + FnStringSplit, + FnGetEnv, + FnUpperCamelCase, + FnLowerCamelCase, + FnSequence, + FnOutputs, + FnFilenames, + FnEval, + FnIfEval, + FnJoinNow, + FnSubNow, + FnRefNow, + FnRefNowOptions, + FnApplyTags, + Tag, + CfnIncludeIntrinsicFunction, + IntrinsicFunction, + + // Utilities + QueryParser, + QueryParsers, + CliOptions, + CfnClientOptions, + YamlTagDefinition, +} from './types.js'; + +// Re-export type guards +export { + isFnInclude, + isFnMap, + isFnRefNow, + isIntrinsicFunction, + isOurExplicitFunction, +} from './type-guards.js'; +``` + +--- + +## Appendix B: npm Scripts + +```json +{ + "scripts": { + "build": "tsc -p tsconfig.build.json", + "build:watch": "tsc -p tsconfig.build.json --watch", + "typecheck": "tsc -p tsconfig.dev.json", + "typecheck:watch": "tsc -p tsconfig.dev.json --watch", + "test:types": "tsd", + "test": "npm run typecheck && npm run test:run", + "clean": "rm -rf dist .tsbuildinfo", + "prepublishOnly": "npm run clean && npm run build" + } +} +``` + +--- + +## Appendix C: Reference Implementation Snippets + +### C.1 Main Include Function (Typed) + +```typescript +// src/index.ts +import type { IncludeOptions, TemplateDocument, ParsedLocation, Scope, RecurseOptions } from './types/index.js'; +import { parseLocation } from './lib/parselocation.js'; +import { recurse } from './lib/recurse.js'; +import { fnInclude } from './lib/include.js'; + +export async function include(options: IncludeOptions): Promise { + let { template } = options; + + options.doEnv = getBoolEnvOpt(options.doEnv, 'CFN_INCLUDE_DO_ENV'); + options.doEval = getBoolEnvOpt(options.doEval, 'CFN_INCLUDE_DO_EVAL'); + + const base: ParsedLocation = parseLocation(options.url); + const scope: Scope = options.scope ?? {}; + + if (base.relative) { + throw new Error('url cannot be relative'); + } + + template = template === undefined + ? await fnInclude({ base, scope, cft: options.url, ...options }) + : template; + + const resolvedTemplate = await Promise.resolve(template); + + return recurse({ + base, + scope, + cft: resolvedTemplate, + rootTemplate: resolvedTemplate, + ...options, + }); +} + +function getBoolEnvOpt(opt: boolean | undefined, envKey: string): boolean { + return process.env[envKey] ? Boolean(process.env[envKey]) : (opt ?? false); +} + +export default include; +export type * from './types/index.js'; +``` + +### C.2 ParseLocation Function (Typed) + +```typescript +// src/lib/parselocation.ts +import type { ParsedLocation } from '../types/index.js'; + +export function parseLocation(location: string | undefined): ParsedLocation { + if (!location) { + return { + protocol: undefined, + host: '', + path: undefined, + relative: true, + raw: '', + }; + } + + const parsed = location.match(/^(((\w+):)?\/\/)?(.*?)([\\\/](.*))?$/); + + if (!parsed) { + throw new Error(`Invalid location format: ${location}`); + } + + return { + protocol: parsed[3], + host: parsed[4] ?? '', + path: parsed[5], + relative: parsed[1] === undefined, + raw: location, + }; +} +``` + +--- + +*Document generated: 2026-02-08* +*Estimated conversion effort: 8-10 developer hours* +*Recommended Node.js version: 20.19+* +*Recommended TypeScript version: 5.4+* diff --git a/docs/PHASE4-CDK-INTEGRATION-ANALYSIS.md b/docs/PHASE4-CDK-INTEGRATION-ANALYSIS.md new file mode 100644 index 0000000..de5cc11 --- /dev/null +++ b/docs/PHASE4-CDK-INTEGRATION-ANALYSIS.md @@ -0,0 +1,635 @@ +# Phase 4: AWS CDK Import/Eject Functionality Analysis + +**Date:** February 8, 2026 +**Author:** TARS (nmccready-tars) +**Status:** Research & Design Complete + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [AWS CDK CfnInclude Module Research](#2-aws-cdk-cfninclude-module-research) +3. [Existing Tools Analysis](#3-existing-tools-analysis) +4. [EJECT Functionality Design (cfn-include → CDK)](#4-eject-functionality-design-cfn-include--cdk) +5. [IMPORT Functionality Design (CDK → cfn-include)](#5-import-functionality-design-cdk--cfn-include) +6. [CLI Design](#6-cli-design) +7. [Challenges and Limitations](#7-challenges-and-limitations) +8. [Implementation Roadmap](#8-implementation-roadmap) +9. [Appendix: cfn-include Function Mapping](#appendix-cfn-include-function-mapping) + +--- + +## 1. Executive Summary + +This document analyzes the feasibility and design of bidirectional integration between `cfn-include` (a CloudFormation template preprocessor) and AWS CDK (Cloud Development Kit). The goal is to enable: + +1. **EJECT** - Convert cfn-include templates to AWS CDK TypeScript/Python code +2. **IMPORT** - Convert CDK-synthesized CloudFormation templates back to optimized cfn-include templates + +### Key Findings + +- **EJECT is feasible** but requires a two-phase approach: first resolve all custom `Fn::*` functions, then convert to CDK +- **IMPORT has limited utility** - CDK templates are already optimized for CDK workflows; reverse engineering patterns is complex +- **Existing tools exist** - `cdk-from-cfn` handles the CloudFormation → CDK conversion well +- **Fn::Eval is non-portable** - Arbitrary JavaScript cannot be safely converted to CDK + +### Recommended Approach + +Implement EJECT as a wrapper around existing tools (`cdk-from-cfn`) with a pre-processing step that resolves cfn-include's custom functions. IMPORT should focus on pattern detection for migration assistance rather than full conversion. + +--- + +## 2. AWS CDK CfnInclude Module Research + +### 2.1 What is @aws-cdk/cloudformation-include? + +The `cloudformation-include` module (now `aws-cdk-lib/cloudformation-include`) allows importing existing CloudFormation templates into CDK applications. It provides: + +```typescript +import * as cfn_inc from 'aws-cdk-lib/cloudformation-include'; + +const cfnTemplate = new cfn_inc.CfnInclude(this, 'Template', { + templateFile: 'my-template.json', +}); +``` + +### 2.2 Key Capabilities + +| Feature | Description | +|---------|-------------| +| **Template Import** | Import JSON/YAML CloudFormation templates | +| **Resource Access** | Get L1 constructs via `getResource('LogicalId')` | +| **L1 → L2 Conversion** | Convert with `fromCfn*()` methods (e.g., `Key.fromCfnKey()`) | +| **Parameter Replacement** | Replace parameters with build-time values | +| **Nested Stacks** | Support for `AWS::CloudFormation::Stack` resources | +| **Preserve Logical IDs** | Option to keep original IDs or rename with CDK algorithm | + +### 2.3 Construct Levels in CDK + +| Level | Description | Example | +|-------|-------------|---------| +| **L1 (CfnXxx)** | 1:1 mapping to CloudFormation resources | `CfnBucket` | +| **L2 (High-Level)** | Abstractions with sensible defaults | `Bucket` | +| **L3 (Patterns)** | Multi-resource patterns | `ApplicationLoadBalancedFargateService` | + +**cfn-include templates produce L1 constructs when imported via CfnInclude**, since they're raw CloudFormation. + +### 2.4 Converting L1 to L2 + +Two methods: + +**Method 1: `fromCfn*()` methods (Preferred)** +```typescript +const cfnKey = cfnTemplate.getResource('Key') as kms.CfnKey; +const key = kms.Key.fromCfnKey(cfnKey); // Mutable L2 +``` + +**Method 2: `fromXxxArn/Name()` methods (Fallback)** +```typescript +const cfnBucket = cfnTemplate.getResource('Bucket') as s3.CfnBucket; +const bucket = s3.Bucket.fromBucketName(this, 'L2Bucket', cfnBucket.ref); // Immutable +``` + +### 2.5 Limitations of CfnInclude + +- Does not execute CloudFormation transforms +- Cannot handle cycles between resources (unless `allowCyclicalReferences: true`) +- Resources using complex `Fn::If` may not convert to L2 +- Custom resources return as generic `CfnResource` + +--- + +## 3. Existing Tools Analysis + +### 3.1 cdk-from-cfn + +**Repository:** `cdklabs/cdk-from-cfn` +**Type:** Rust CLI with WASM bindings (npm package available) + +**Features:** +- Converts CloudFormation JSON/YAML to CDK code +- Supports: TypeScript, Python, Java, Go, C# +- Generates either `Stack` or `Construct` classes +- Handles most intrinsic functions + +**Supported Intrinsic Functions:** +- ✅ `Fn::FindInMap`, `Fn::Join`, `Fn::Sub`, `Ref` +- ✅ `Fn::And`, `Fn::Equals`, `Fn::If`, `Fn::Not`, `Fn::Or` +- ✅ `Fn::GetAtt`, `Fn::Base64`, `Fn::ImportValue`, `Fn::Select` +- ✅ `Fn::GetAZs`, `Fn::Cidr` +- ❌ SSM/SecretsManager dynamic references +- ❌ Create policies + +**Usage:** +```bash +# CLI +cdk-from-cfn template.json output.ts --language typescript --stack-name MyStack + +# Node.js +import * as cdk_from_cfn from 'cdk-from-cfn'; +const cdkCode = cdk_from_cfn.transmute(template, 'typescript', 'MyStack'); +``` + +### 3.2 AWS CloudFormation Registry + +The CDK uses CloudFormation schema definitions from the registry to generate L1 constructs. This is why `cdk-from-cfn` can accurately map resources. + +### 3.3 Other Tools + +| Tool | Description | Status | +|------|-------------|--------| +| **former2** | Generates IaC from existing AWS resources | Alternative approach | +| **AWS CDK Migrate** | Official tool for importing existing stacks | Stack-focused, not template-focused | + +--- + +## 4. EJECT Functionality Design (cfn-include → CDK) + +### 4.1 Overview + +Convert a cfn-include template (with custom `Fn::*` functions) to AWS CDK code. + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ cfn-include │────▶│ Standard CFN │────▶│ CDK Code │ +│ Template │ │ Template │ │ (TS/Python) │ +│ (Fn::Include, │ │ (Resolved) │ │ │ +│ Fn::Map, etc) │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + Phase 1 Phase 2 Phase 3 + (cfn-include) (cdk-from-cfn) (Code Generation) +``` + +### 4.2 Phase 1: Resolve cfn-include Functions + +Use the existing `cfn-include` CLI to resolve all custom functions: + +```bash +cfn-include input.yml --enable env,eval > resolved.json +``` + +This handles: +- `Fn::Include` → File contents inlined +- `Fn::Map` → Array expanded +- `Fn::Flatten` → Arrays flattened +- `Fn::Merge` → Objects merged +- `Fn::RefNow` → References resolved +- etc. + +### 4.3 Phase 2: Convert to CDK + +Pass the resolved CloudFormation template to `cdk-from-cfn`: + +```typescript +import * as cdk_from_cfn from 'cdk-from-cfn'; +import * as cfnInclude from '@znemz/cfn-include'; + +async function eject(templatePath: string, language: string) { + // Phase 1: Resolve cfn-include functions + const resolved = await cfnInclude({ + url: `file://${templatePath}`, + doEnv: true, + doEval: true, + }); + + // Phase 2: Convert to CDK + const cdkCode = cdk_from_cfn.transmute( + JSON.stringify(resolved), + language, + 'GeneratedStack' + ); + + return cdkCode; +} +``` + +### 4.4 Phase 3: Output Structure + +Generate a complete CDK application structure: + +``` +output/ +├── package.json +├── tsconfig.json +├── cdk.json +├── lib/ +│ └── generated-stack.ts # Generated CDK code +├── bin/ +│ └── app.ts # CDK app entry point +└── README.md # Migration notes +``` + +### 4.5 Preserving Intent with Comments + +Since cfn-include functions carry semantic meaning that's lost in resolution, we should: + +1. **Parse original template** to detect cfn-include function usage +2. **Generate comments** in output explaining original patterns + +```typescript +// Originally: Fn::Map over [80, 443] with template for each port +const securityGroupIngress80 = new ec2.CfnSecurityGroupIngress(this, 'Ingress80', { + // ... +}); +const securityGroupIngress443 = new ec2.CfnSecurityGroupIngress(this, 'Ingress443', { + // ... +}); +``` + +### 4.6 Handling Special Cases + +#### Fn::Eval (JavaScript Evaluation) +```yaml +# NOT CONVERTIBLE - Contains arbitrary JS +Fn::Eval: + state: [1, 2, 3] + script: "state.map(v => v * 2);" +``` +**Strategy:** Warn user, output as comment with manual intervention required. + +#### Fn::Include with dynamic paths +```yaml +# Dynamic path based on environment +Fn::Include: "${ENVIRONMENT}/config.yml" +``` +**Strategy:** Resolve at eject time with specific environment, document the source. + +#### Fn::IfEval (Conditional JS) +```yaml +Fn::IfEval: + evalCond: "('${ENV}' === 'prod')" + truthy: { ... } + falsy: { ... } +``` +**Strategy:** Evaluate at eject time, document the condition that was used. + +--- + +## 5. IMPORT Functionality Design (CDK → cfn-include) + +### 5.1 Overview + +Convert a CDK-synthesized CloudFormation template back to an optimized cfn-include template. + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ CDK Stack │────▶│ cdk synth │────▶│ cfn-include │ +│ (TypeScript) │ │ output.json │ │ optimized.yml │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + Pattern Detection + - Repeated resources + - Similar structures + - Extractable modules +``` + +### 5.2 Pattern Detection Algorithms + +#### 5.2.1 Repeated Resource Pattern → Fn::Map + +Detect resources with similar structure that differ only in specific values: + +```json +// Input (synthesized CFN) +{ + "SubnetA": { "Type": "AWS::EC2::Subnet", "Properties": { "AvailabilityZone": "us-east-1a" } }, + "SubnetB": { "Type": "AWS::EC2::Subnet", "Properties": { "AvailabilityZone": "us-east-1b" } }, + "SubnetC": { "Type": "AWS::EC2::Subnet", "Properties": { "AvailabilityZone": "us-east-1c" } } +} +``` + +```yaml +# Output (cfn-include) +Fn::Merge: + Fn::Map: + - [a, b, c] + - AZ + - Subnet${AZ}: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: !Sub us-east-1${AZ} +``` + +**Algorithm:** +1. Group resources by Type +2. Compare property structures (ignoring values) +3. Identify varying fields +4. Check if variations follow a pattern (sequential, from list, etc.) +5. Generate `Fn::Map` if pattern detected + +#### 5.2.2 Similar Structure Detection → Fn::Include + +Identify resource blocks that could be extracted to reusable includes: + +```json +// Input: Multiple Lambda functions with similar config +{ + "Function1": { "Type": "AWS::Lambda::Function", "Properties": { "Runtime": "nodejs18.x", "Handler": "index.handler", "MemorySize": 256 } }, + "Function2": { "Type": "AWS::Lambda::Function", "Properties": { "Runtime": "nodejs18.x", "Handler": "index.handler", "MemorySize": 256 } } +} +``` + +```yaml +# Output: Extracted include +# includes/lambda-defaults.yml +Type: AWS::Lambda::Function +Properties: + Runtime: nodejs18.x + Handler: index.handler + MemorySize: 256 + +# main.yml +Function1: + Fn::DeepMerge: + - Fn::Include: includes/lambda-defaults.yml + - Properties: + FunctionName: function-1 +Function2: + Fn::DeepMerge: + - Fn::Include: includes/lambda-defaults.yml + - Properties: + FunctionName: function-2 +``` + +### 5.3 Optimization Strategies + +| Strategy | Description | Trigger | +|----------|-------------|---------| +| **Fn::Map extraction** | Convert repeated resources to map | 3+ similar resources | +| **Fn::Include extraction** | Extract common patterns to files | Repeated structures | +| **Fn::Merge usage** | Combine base + overrides | Inheritance patterns | +| **Fn::Sequence generation** | Detect sequential patterns | A, B, C or 1, 2, 3 patterns | + +### 5.4 Limitations of IMPORT + +1. **Loss of L2 abstraction** - CDK synthesizes to L1, can't recover L2 intent +2. **CDK metadata** - Contains CDK-specific constructs that don't reverse well +3. **Token resolution** - CDK tokens are resolved, original references lost +4. **Code generation impossible** - Can't regenerate original TypeScript/Python + +### 5.5 Recommended Use Cases for IMPORT + +- **Migration assistance** - Help identify patterns when migrating away from CDK +- **Template optimization** - Reduce template size by extracting patterns +- **Documentation** - Generate simplified cfn-include views of complex CDK stacks + +--- + +## 6. CLI Design + +### 6.1 EJECT Command + +```bash +cfn-include eject