declapract-typescript-ehmpathy 0.47.18 → 0.47.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ /**
6
+ * .what = creates a mock context with apikeys config in a temp directory
7
+ * .why = enables testing buildWorkflowSecretsBlock with different apikey configs
8
+ */
9
+ export const withApikeysContext = async (
10
+ input: { apikeys: string[] },
11
+ fn: (context: { getProjectRootDirectory: () => string }) => Promise<void>,
12
+ ): Promise<void> => {
13
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
14
+ const configDir = path.join(
15
+ tempDir,
16
+ '.agent',
17
+ 'repo=.this',
18
+ 'role=any',
19
+ 'skills',
20
+ );
21
+ fs.mkdirSync(configDir, { recursive: true });
22
+ fs.writeFileSync(
23
+ path.join(configDir, 'use.apikeys.json'),
24
+ JSON.stringify({ apikeys: { required: input.apikeys } }, null, 2),
25
+ );
26
+ try {
27
+ await fn({ getProjectRootDirectory: () => tempDir });
28
+ } finally {
29
+ fs.rmSync(tempDir, { recursive: true, force: true });
30
+ }
31
+ };
@@ -0,0 +1,32 @@
1
+ import { FileCheckType, type FileFixFunction } from 'declapract';
2
+
3
+ /**
4
+ * .what = check that use.apikeys.json exists
5
+ * .why = enables projects to declare which api keys are required for integration tests
6
+ */
7
+ export const check = FileCheckType.EXISTS;
8
+
9
+ /**
10
+ * .what = creates default use.apikeys.json structure if file doesn't exist
11
+ * .why = ensures all projects have the config file with proper schema
12
+ */
13
+ export const fix: FileFixFunction = (contents) => {
14
+ // if file already exists, preserve its content
15
+ if (contents) {
16
+ return { contents };
17
+ }
18
+
19
+ // create default structure
20
+ return {
21
+ contents:
22
+ JSON.stringify(
23
+ {
24
+ apikeys: {
25
+ required: [],
26
+ },
27
+ },
28
+ null,
29
+ 2,
30
+ ) + '\n',
31
+ };
32
+ };
@@ -25,7 +25,7 @@ jobs:
25
25
  # run tests in parallel
26
26
  test-commits:
27
27
  runs-on: ubuntu-24.04
28
- needs: [install]
28
+ needs: [install, test-shards-omit]
29
29
  steps:
30
30
  - name: checkout
31
31
  uses: actions/checkout@v4
@@ -48,7 +48,7 @@ jobs:
48
48
 
49
49
  test-types:
50
50
  runs-on: ubuntu-24.04
51
- needs: [install]
51
+ needs: [install, test-shards-omit]
52
52
  steps:
53
53
  - name: checkout
54
54
  uses: actions/checkout@v4
@@ -69,7 +69,7 @@ jobs:
69
69
 
70
70
  test-format:
71
71
  runs-on: ubuntu-24.04
72
- needs: [install]
72
+ needs: [install, test-shards-omit]
73
73
  steps:
74
74
  - name: checkout
75
75
  uses: actions/checkout@v4
@@ -93,7 +93,7 @@ jobs:
93
93
 
94
94
  test-lint:
95
95
  runs-on: ubuntu-24.04
96
- needs: [install]
96
+ needs: [install, test-shards-omit]
97
97
  steps:
98
98
  - name: checkout
99
99
  uses: actions/checkout@v4
@@ -114,7 +114,7 @@ jobs:
114
114
 
115
115
  test-unit:
116
116
  runs-on: ubuntu-24.04
117
- needs: [install]
117
+ needs: [install, test-shards-omit]
118
118
  steps:
119
119
  - name: checkout
120
120
  uses: actions/checkout@v4
@@ -133,9 +133,56 @@ jobs:
133
133
  - name: test:unit
134
134
  run: THOROUGH=true npm run test:unit
135
135
 
136
- test-integration:
136
+ # shard setup job
137
+ enshard:
138
+ name: enshard
139
+ runs-on: ubuntu-24.04
140
+ outputs:
141
+ integration-matrix: ${{ steps.integration.outputs.matrix }}
142
+ integration-patterns: ${{ steps.integration.outputs.patterns }}
143
+ acceptance-matrix: ${{ steps.acceptance.outputs.matrix }}
144
+ acceptance-patterns: ${{ steps.acceptance.outputs.patterns }}
145
+ steps:
146
+ - name: checkout
147
+ uses: actions/checkout@v4
148
+ with:
149
+ sparse-checkout: |
150
+ jest.integration.shards.jsonc
151
+ jest.acceptance.shards.jsonc
152
+ .github/actions/test-shards-setup
153
+ sparse-checkout-cone-mode: false
154
+
155
+ - name: setup integration shards
156
+ id: integration
157
+ uses: ./.github/actions/test-shards-setup
158
+ with:
159
+ config-file: jest.integration.shards.jsonc
160
+ test-type: integration
161
+
162
+ - name: setup acceptance shards
163
+ id: acceptance
164
+ uses: ./.github/actions/test-shards-setup
165
+ with:
166
+ config-file: jest.acceptance.shards.jsonc
167
+ test-type: acceptance
168
+
169
+ # placeholder job for github actions visualization alignment
170
+ # note: github actions has no column position controls;
171
+ # this job pushes test-* jobs one column right to align with test-shards-* column
172
+ test-shards-omit:
137
173
  runs-on: ubuntu-24.04
138
174
  needs: [install]
175
+ steps:
176
+ - run: echo "👌 placeholder for column alignment"
177
+
178
+ # shard runner jobs
179
+ test-shards-integration:
180
+ runs-on: ubuntu-24.04
181
+ needs: [install, enshard]
182
+ strategy:
183
+ fail-fast: false
184
+ matrix:
185
+ shard: ${{ fromJson(needs.enshard.outputs.integration-matrix) }}
139
186
  steps:
140
187
  - name: checkout
141
188
  uses: actions/checkout@v4
@@ -164,12 +211,44 @@ jobs:
164
211
  - name: start:livedb:dev
165
212
  run: npm run start:livedb:dev --if-present
166
213
 
167
- - name: test:integration
168
- run: THOROUGH=true npm run test:integration
169
-
170
- test-acceptance-locally:
214
+ - name: build
215
+ run: npm run build
216
+
217
+ - name: test:integration (explicit ${{ matrix.shard.index }})
218
+ if: matrix.shard.type == 'explicit'
219
+ run: |
220
+ patterns='${{ join(matrix.shard.patterns, '|') }}'
221
+ THOROUGH=true npm run test:integration -- --testPathPattern="$patterns" --json --outputFile=jest-results.json
222
+
223
+ - name: test:integration (dynamic ${{ matrix.shard.shard }}/${{ matrix.shard.total }})
224
+ if: matrix.shard.type == 'dynamic'
225
+ run: |
226
+ exclude='${{ needs.enshard.outputs.integration-patterns }}'
227
+ if [[ -n "$exclude" ]]; then
228
+ THOROUGH=true npm run test:integration -- --testPathIgnorePatterns="$exclude" --shard=${{ matrix.shard.shard }}/${{ matrix.shard.total }} --json --outputFile=jest-results.json
229
+ else
230
+ THOROUGH=true npm run test:integration -- --shard=${{ matrix.shard.shard }}/${{ matrix.shard.total }} --json --outputFile=jest-results.json
231
+ fi
232
+
233
+ - name: report slow tests
234
+ if: always() && hashFiles('jest-results.json') != ''
235
+ run: |
236
+ jq -r '.testResults[] | "\(.name):\(.perfStats.runtime)"' jest-results.json | while IFS=: read -r name runtime; do
237
+ seconds=$((runtime / 1000))
238
+ if [[ $seconds -ge 30 ]]; then
239
+ echo "::warning file=${name}::slow test: ${seconds}s"
240
+ elif [[ $seconds -ge 10 ]]; then
241
+ echo "::notice file=${name}::test duration: ${seconds}s"
242
+ fi
243
+ done
244
+
245
+ test-shards-acceptance:
171
246
  runs-on: ubuntu-24.04
172
- needs: [install]
247
+ needs: [install, enshard]
248
+ strategy:
249
+ fail-fast: false
250
+ matrix:
251
+ shard: ${{ fromJson(needs.enshard.outputs.acceptance-matrix) }}
173
252
  steps:
174
253
  - name: checkout
175
254
  uses: actions/checkout@v4
@@ -198,5 +277,63 @@ jobs:
198
277
  - name: start:livedb:dev
199
278
  run: npm run start:livedb:dev --if-present
200
279
 
201
- - name: test:acceptance:locally
202
- run: THOROUGH=true npm run test:acceptance:locally --if-present
280
+ - name: build
281
+ run: npm run build
282
+
283
+ - name: test:acceptance (explicit ${{ matrix.shard.index }})
284
+ if: matrix.shard.type == 'explicit'
285
+ run: |
286
+ patterns='${{ join(matrix.shard.patterns, '|') }}'
287
+ THOROUGH=true npm run test:acceptance -- --testPathPattern="$patterns" --json --outputFile=jest-results.json
288
+
289
+ - name: test:acceptance (dynamic ${{ matrix.shard.shard }}/${{ matrix.shard.total }})
290
+ if: matrix.shard.type == 'dynamic'
291
+ run: |
292
+ exclude='${{ needs.enshard.outputs.acceptance-patterns }}'
293
+ if [[ -n "$exclude" ]]; then
294
+ THOROUGH=true npm run test:acceptance -- --testPathIgnorePatterns="$exclude" --shard=${{ matrix.shard.shard }}/${{ matrix.shard.total }} --json --outputFile=jest-results.json
295
+ else
296
+ THOROUGH=true npm run test:acceptance -- --shard=${{ matrix.shard.shard }}/${{ matrix.shard.total }} --json --outputFile=jest-results.json
297
+ fi
298
+
299
+ - name: report slow tests
300
+ if: always() && hashFiles('jest-results.json') != ''
301
+ run: |
302
+ jq -r '.testResults[] | "\(.name):\(.perfStats.runtime)"' jest-results.json | while IFS=: read -r name runtime; do
303
+ seconds=$((runtime / 1000))
304
+ if [[ $seconds -ge 30 ]]; then
305
+ echo "::warning file=${name}::slow test: ${seconds}s"
306
+ elif [[ $seconds -ge 10 ]]; then
307
+ echo "::notice file=${name}::test duration: ${seconds}s"
308
+ fi
309
+ done
310
+
311
+ # union job: aggregates shard results into single status for pr merge constraints
312
+ test-integration:
313
+ runs-on: ubuntu-24.04
314
+ needs: [install, enshard, test-shards-integration]
315
+ if: always() && needs.install.result == 'success' && needs.enshard.result == 'success'
316
+ steps:
317
+ - name: report shard results
318
+ run: |
319
+ if [[ "${{ needs.test-shards-integration.result }}" == "success" ]]; then
320
+ echo "👌 all integration test shards passed"
321
+ else
322
+ echo "::error::some integration test shards failed"
323
+ exit 1
324
+ fi
325
+
326
+ # union job: aggregates shard results into single status for pr merge constraints
327
+ test-acceptance-locally:
328
+ runs-on: ubuntu-24.04
329
+ needs: [install, enshard, test-shards-acceptance]
330
+ if: always() && needs.install.result == 'success' && needs.enshard.result == 'success'
331
+ steps:
332
+ - name: report shard results
333
+ run: |
334
+ if [[ "${{ needs.test-shards-acceptance.result }}" == "success" ]]; then
335
+ echo "👌 all acceptance test shards passed"
336
+ else
337
+ echo "::error::some acceptance test shards failed"
338
+ exit 1
339
+ fi
@@ -0,0 +1,91 @@
1
+ import type { FileCheckFunction, FileFixFunction } from 'declapract';
2
+
3
+ import { readUseApikeysConfig } from '../../../../../utils/readUseApikeysConfig';
4
+
5
+ /**
6
+ * .what = builds the expected .test.yml content with apikey secrets injected
7
+ * .why = single source of truth for both check and fix
8
+ */
9
+ export const buildExpectedContent = (input: {
10
+ template: string;
11
+ apikeys: string[];
12
+ }): string => {
13
+ let result = input.template;
14
+
15
+ // if no apikeys, return template as-is
16
+ if (!input.apikeys.length) {
17
+ return result;
18
+ }
19
+
20
+ // build secrets declaration block for workflow_call
21
+ const secretsDeclaration = input.apikeys
22
+ .map(
23
+ (key) =>
24
+ ` ${key}:\n description: "api key for ${key.toLowerCase().replace(/_/g, ' ')}"\n required: false`,
25
+ )
26
+ .join('\n');
27
+
28
+ // build env block for test-integration job
29
+ const envBlock = input.apikeys
30
+ .map((key) => ` ${key}: \${{ secrets.${key} }}`)
31
+ .join('\n');
32
+
33
+ // insert secrets declaration after workflow_call inputs
34
+ // look for the pattern: inputs: ... (multiline) followed by blank line or permissions:
35
+ result = result.replace(
36
+ /(on:\n workflow_call:\n inputs:\n(?: [^\n]+\n)+)/,
37
+ `$1 secrets:\n${secretsDeclaration}\n\n`,
38
+ );
39
+
40
+ // insert env block to test-integration job
41
+ // add env block after needs: [install] line
42
+ result = result.replace(
43
+ /(test-integration:\n runs-on: ubuntu-24\.04\n needs: \[install\]\n)/,
44
+ `$1 env:\n${envBlock}\n`,
45
+ );
46
+
47
+ return result;
48
+ };
49
+
50
+ /**
51
+ * .what = ensures .test.yml matches expected content with apikey secrets
52
+ * .why = enables integration tests to access required api keys via github secrets
53
+ */
54
+ export const check: FileCheckFunction = async (contents, context) => {
55
+ // read apikeys from project
56
+ const apikeysConfig = await readUseApikeysConfig({
57
+ projectRootDirectory: context.getProjectRootDirectory(),
58
+ });
59
+
60
+ // build expected content from template + apikeys
61
+ const expected = buildExpectedContent({
62
+ template: context.declaredFileContents ?? '',
63
+ apikeys: apikeysConfig?.apikeys?.required ?? [],
64
+ });
65
+
66
+ // if contents match expected, file passes (throw)
67
+ if (contents === expected) {
68
+ throw new Error('file matches expected content');
69
+ }
70
+
71
+ // return = file differs from expected (bad practice detected)
72
+ };
73
+
74
+ /**
75
+ * .what = fixes .test.yml to include apikey secrets declaration and env vars
76
+ * .why = ensures integration tests have access to required api keys
77
+ */
78
+ export const fix: FileFixFunction = async (_contents, context) => {
79
+ // read apikeys from project
80
+ const apikeysConfig = await readUseApikeysConfig({
81
+ projectRootDirectory: context.getProjectRootDirectory(),
82
+ });
83
+
84
+ // build expected content from template + apikeys
85
+ const expected = buildExpectedContent({
86
+ template: context.declaredFileContents ?? '',
87
+ apikeys: apikeysConfig?.apikeys?.required ?? [],
88
+ });
89
+
90
+ return { contents: expected };
91
+ };
@@ -0,0 +1,40 @@
1
+ import type { FileCheckFunction, FileFixFunction } from 'declapract';
2
+ import { UnexpectedCodePathError } from 'helpful-errors';
3
+
4
+ import { buildWorkflowSecretsBlock } from '../../../../../utils/buildWorkflowSecretsBlock';
5
+
6
+ /**
7
+ * .what = ensures test.yml matches expected content with apikey secrets block
8
+ * .why = enables the reusable workflow to access github secrets
9
+ */
10
+ export const check: FileCheckFunction = async (contents, context) => {
11
+ // fail fast if template not found
12
+ if (!context.declaredFileContents)
13
+ throw new UnexpectedCodePathError('declaredFileContents not found', {
14
+ relativeFilePath: context.relativeFilePath,
15
+ });
16
+
17
+ const expected = await buildWorkflowSecretsBlock(
18
+ { template: context.declaredFileContents },
19
+ context,
20
+ );
21
+ if (contents === expected) throw new Error('file matches expected content');
22
+ };
23
+
24
+ /**
25
+ * .what = fixes test.yml to include apikey secrets block
26
+ * .why = ensures the reusable workflow receives required api keys
27
+ */
28
+ export const fix: FileFixFunction = async (_contents, context) => {
29
+ // fail fast if template not found
30
+ if (!context.declaredFileContents)
31
+ throw new UnexpectedCodePathError('declaredFileContents not found', {
32
+ relativeFilePath: context.relativeFilePath,
33
+ });
34
+
35
+ const expected = await buildWorkflowSecretsBlock(
36
+ { template: context.declaredFileContents },
37
+ context,
38
+ );
39
+ return { contents: expected };
40
+ };
@@ -0,0 +1,40 @@
1
+ import type { FileCheckFunction, FileFixFunction } from 'declapract';
2
+ import { UnexpectedCodePathError } from 'helpful-errors';
3
+
4
+ import { buildWorkflowSecretsBlock } from '../../../../../utils/buildWorkflowSecretsBlock';
5
+
6
+ /**
7
+ * .what = ensures publish.yml matches expected content with apikey secrets block
8
+ * .why = enables the reusable workflow to access github secrets during publish
9
+ */
10
+ export const check: FileCheckFunction = async (contents, context) => {
11
+ // fail fast if template not found
12
+ if (!context.declaredFileContents)
13
+ throw new UnexpectedCodePathError('declaredFileContents not found', {
14
+ relativeFilePath: context.relativeFilePath,
15
+ });
16
+
17
+ const expected = await buildWorkflowSecretsBlock(
18
+ { template: context.declaredFileContents },
19
+ context,
20
+ );
21
+ if (contents === expected) throw new Error('file matches expected content');
22
+ };
23
+
24
+ /**
25
+ * .what = fixes publish.yml to include apikey secrets block
26
+ * .why = ensures the reusable workflow receives required api keys during publish
27
+ */
28
+ export const fix: FileFixFunction = async (_contents, context) => {
29
+ // fail fast if template not found
30
+ if (!context.declaredFileContents)
31
+ throw new UnexpectedCodePathError('declaredFileContents not found', {
32
+ relativeFilePath: context.relativeFilePath,
33
+ });
34
+
35
+ const expected = await buildWorkflowSecretsBlock(
36
+ { template: context.declaredFileContents },
37
+ context,
38
+ );
39
+ return { contents: expected };
40
+ };
@@ -0,0 +1,40 @@
1
+ import type { FileCheckFunction, FileFixFunction } from 'declapract';
2
+ import { UnexpectedCodePathError } from 'helpful-errors';
3
+
4
+ import { buildWorkflowSecretsBlock } from '../../../../../utils/buildWorkflowSecretsBlock';
5
+
6
+ /**
7
+ * .what = ensures deploy.yml matches expected content with apikey secrets block
8
+ * .why = enables the reusable workflow to access github secrets during deploy
9
+ */
10
+ export const check: FileCheckFunction = async (contents, context) => {
11
+ // fail fast if template not found
12
+ if (!context.declaredFileContents)
13
+ throw new UnexpectedCodePathError('declaredFileContents not found', {
14
+ relativeFilePath: context.relativeFilePath,
15
+ });
16
+
17
+ const expected = await buildWorkflowSecretsBlock(
18
+ { template: context.declaredFileContents },
19
+ context,
20
+ );
21
+ if (contents === expected) throw new Error('file matches expected content');
22
+ };
23
+
24
+ /**
25
+ * .what = fixes deploy.yml to include apikey secrets block
26
+ * .why = enables the reusable workflow receives required api keys during deploy
27
+ */
28
+ export const fix: FileFixFunction = async (_contents, context) => {
29
+ // fail fast if template not found
30
+ if (!context.declaredFileContents)
31
+ throw new UnexpectedCodePathError('declaredFileContents not found', {
32
+ relativeFilePath: context.relativeFilePath,
33
+ });
34
+
35
+ const expected = await buildWorkflowSecretsBlock(
36
+ { template: context.declaredFileContents },
37
+ context,
38
+ );
39
+ return { contents: expected };
40
+ };
@@ -0,0 +1,35 @@
1
+ import { readUseApikeysConfig } from './readUseApikeysConfig';
2
+
3
+ /**
4
+ * .what = builds workflow content with apikey secrets block for .test.yml
5
+ * .why = single source of truth for test.yml, publish.yml, deploy.yml check+fix
6
+ */
7
+ export const buildWorkflowSecretsBlock = async (
8
+ input: { template: string },
9
+ context: { getProjectRootDirectory: () => string },
10
+ ): Promise<string> => {
11
+ // read apikeys from project
12
+ const apikeysConfig = await readUseApikeysConfig({
13
+ projectRootDirectory: context.getProjectRootDirectory(),
14
+ });
15
+ const apikeys = apikeysConfig?.apikeys?.required ?? [];
16
+
17
+ // if no apikeys, return template as-is
18
+ if (!apikeys.length) {
19
+ return input.template;
20
+ }
21
+
22
+ // build secrets block
23
+ const secretsBlock = apikeys
24
+ .map((key) => ` ${key}: \${{ secrets.${key} }}`)
25
+ .join('\n');
26
+
27
+ // find jobs that call .test.yml with 'with:' block and add secrets block after
28
+ // handle cases with optional 'if:' before 'with:'
29
+ const result = input.template.replace(
30
+ /(uses: \.\/\.github\/workflows\/\.test\.yml\n(?: if: [^\n]+\n)? with:\n(?: [^\n]+\n)+)/g,
31
+ `$1 secrets:\n${secretsBlock}\n`,
32
+ );
33
+
34
+ return result;
35
+ };
@@ -0,0 +1,43 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import util from 'util';
4
+
5
+ /**
6
+ * .what = configuration structure for use.apikeys.json
7
+ * .why = defines which api keys are required for integration tests
8
+ */
9
+ export interface UseApikeysConfig {
10
+ apikeys: {
11
+ required: string[];
12
+ };
13
+ }
14
+
15
+ const USE_APIKEYS_PATH = '.agent/repo=.this/role=any/skills/use.apikeys.json';
16
+
17
+ /**
18
+ * .what = reads and parses use.apikeys.json from target project
19
+ * .why = centralizes apikey config access for all workflow practices
20
+ */
21
+ export const readUseApikeysConfig = async (input: {
22
+ projectRootDirectory: string;
23
+ }): Promise<UseApikeysConfig | null> => {
24
+ // build full path to config file
25
+ const configPath = path.join(input.projectRootDirectory, USE_APIKEYS_PATH);
26
+
27
+ // check if file exists
28
+ const exists = await util
29
+ .promisify(fs.access)(configPath, fs.constants.F_OK)
30
+ .then(() => true)
31
+ .catch(() => false);
32
+ if (!exists) return null;
33
+
34
+ // read and parse the file
35
+ try {
36
+ const contents = await util.promisify(fs.readFile)(configPath, 'utf-8');
37
+ const parsed = JSON.parse(contents) as UseApikeysConfig;
38
+ return parsed;
39
+ } catch {
40
+ // handle malformed json gracefully
41
+ return null;
42
+ }
43
+ };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "declapract-typescript-ehmpathy",
3
3
  "author": "ehmpathy",
4
4
  "description": "declapract best practices declarations for typescript",
5
- "version": "0.47.18",
5
+ "version": "0.47.20",
6
6
  "license": "MIT",
7
7
  "main": "src/index.js",
8
8
  "repository": "ehmpathy/declapract-typescript-ehmpathy",
@@ -76,7 +76,7 @@
76
76
  "husky": "8.0.3",
77
77
  "jest": "30.2.0",
78
78
  "rhachet": "1.20.5",
79
- "rhachet-roles-bhrain": "0.5.8",
79
+ "rhachet-roles-bhrain": "0.5.9",
80
80
  "rhachet-roles-bhuild": "0.5.6",
81
81
  "rhachet-roles-ehmpathy": "1.17.11",
82
82
  "tsc-alias": "1.8.10",
@@ -1,4 +0,0 @@
1
- import { FileCheckType } from 'declapract';
2
-
3
- // check that the file exists; contents are user-defined
4
- export const check = FileCheckType.EXISTS;