@freshworks/shiftleft-tools 1.1.18 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,6 +70,18 @@ shiftleft --version
70
70
 
71
71
  ## Usage
72
72
 
73
+ | Command | Purpose | Key flags |
74
+ |---|---|---|
75
+ | `init-rules` | Symlink Cursor rules/skills into a repo | `--force`, `--skip-skills`, `--copy`, `--stack` |
76
+ | `init-postman` | Scaffold Postman/Newman infra | `--name`, `--stack`, `--with-staging`, `--force` |
77
+ | `link` | (Re)create Cursor symlinks | `--copy`, `--stack` |
78
+ | `test` | Stage latest scripts + run orchestrator | *(flags pass through to run-all.sh)* |
79
+ | `stage-scripts` | Stage library scripts only (no run) | `--stack` |
80
+ | `protect` | Mark a staged script repo-owned | `--list`, `--remove` |
81
+ | `update` | Refresh rules/skills/postman | `--rules`, `--skills`, `--postman`, `--force` |
82
+ | `doctor` | Report drift vs latest | `--check`, `--json` |
83
+ | `setup-pipeline` | Audit + scaffold pipeline | `-y/--yes`, `--force` |
84
+
73
85
  ### Claude Code skills: install the plugin (once per machine)
74
86
 
75
87
  Claude Code skills are delivered as a **plugin**, not copied into each repo — so
@@ -166,6 +178,10 @@ postman/scripts/
166
178
  └── database/ # Node
167
179
  ```
168
180
 
181
+ Java LocalStack/WireMock orchestration auto-detects the container runtime — **docker preferred, podman fallback** (`infra/container-runtime.sh`) — so S3/attachment tests run on podman-only machines.
182
+
183
+ Java `runners/run-tests-local.sh` / `run-tests-staging.sh` ship **generic**. `/setup-api-tests` fills the repo-specific block (port, profile, Maven module, coverage package, JWT) and then runs `shiftleft protect` so staging won't overwrite your customizations.
184
+
169
185
  The library scripts are *not vendored*: `shiftleft test` (and the `run-all.sh`
170
186
  shim) re-stages them from the installed package before every run, so
171
187
  `npm i -g @freshworks/shiftleft-tools@latest` changes test behavior in every repo
@@ -186,9 +202,7 @@ shiftleft stage-scripts # stage only (before calling staged scrip
186
202
 
187
203
  ### Protect a customized library script
188
204
 
189
- Staging overwrites library scripts from the package on every run. If your repo
190
- has customized one (e.g. a service-specific `runners/run-tests-local.sh`), mark
191
- it **protected** so staging leaves it alone:
205
+ Staging overwrites library scripts from the package on every run. For Java repos, **`shiftleft protect` is the required final step of `/setup-api-tests`** — without it, every `shiftleft test` run silently discards your runner customizations. If you've manually customized a script, mark it **protected** so staging leaves it alone:
192
206
 
193
207
  ```bash
194
208
  shiftleft protect runners/run-tests-local.sh runners/run-tests-staging.sh
@@ -343,7 +357,7 @@ npm pack
343
357
  ## Requirements
344
358
 
345
359
  - Node.js 18+ and npm 8.4+ (for the CLI and Postman/Newman runner)
346
- - **Java projects:** Java 21+, Maven
360
+ - **Java projects:** Java (version per your service; the local runner honors `JAVA_VERSION`), Maven, Docker or Podman
347
361
  - **Node projects:** Yarn (recommended), Mocha, nyc, Stryker
348
362
  - AWS CLI (for staging Postman tests with JWT from Secrets Manager)
349
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freshworks/shiftleft-tools",
3
- "version": "1.1.18",
3
+ "version": "1.1.19",
4
4
  "description": "CLI for managing Cursor rules/skills and Postman test infrastructure across Java Spring Boot and Node.js/Express projects",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,178 @@
1
+ """
2
+ Publish a normalized test-health row to DynamoDB after each ShiftLeft run.
3
+
4
+ Gated: only writes when SHIFTLEFT_PUBLISH_METRICS=true and SHIFTLEFT_HEALTH_TABLE is set.
5
+ A publish failure logs a warning and never fails the build.
6
+ """
7
+
8
+ import os
9
+ import re
10
+
11
+
12
+ def _normalize_repo(url):
13
+ """git@github.com:owner/repo.git or https://github.com/owner/repo.git → owner/repo"""
14
+ url = (url or '').strip()
15
+ # SSH
16
+ m = re.match(r'git@[^:]+:(.+?)(?:\.git)?$', url)
17
+ if m:
18
+ return m.group(1)
19
+ # HTTPS
20
+ m = re.match(r'https?://[^/]+/(.+?)(?:\.git)?$', url)
21
+ if m:
22
+ return m.group(1)
23
+ return url
24
+
25
+
26
+ def build_row(metrics, meta):
27
+ """
28
+ Build a normalized DynamoDB item from computed metrics and run context.
29
+
30
+ metrics keys (all optional):
31
+ mutation – dict with keys killed/survived/no_coverage(or noCoverage)/timeout/total
32
+ api_coverage – api-coverage-matrix JSON dict
33
+ postman – dict: assertions_total/passed/failed, collections_passed/failed
34
+ postman_collections – list of per-collection dicts (name, assertions_total/passed/failed, status)
35
+
36
+ meta keys:
37
+ git_repo, git_commit, git_branch, pr_number
38
+ datetime – ISO-8601 string (UTC)
39
+ display_name – human-readable service name
40
+ stack – 'Java' | 'NodeJS' etc.
41
+ environment – 'local' | 'staging'
42
+ mutation_tool – 'pit' | 'stryker'
43
+ products – optional list of product slugs (multi-product repos)
44
+ """
45
+ postman = metrics.get('postman') or {}
46
+ api_coverage = metrics.get('api_coverage')
47
+ mutation = metrics.get('mutation')
48
+ postman_collections = metrics.get('postman_collections') or []
49
+
50
+ # --- passRate ---
51
+ assertions_total = int(postman.get('assertions_total', 0))
52
+ assertions_passed = int(postman.get('assertions_passed', 0))
53
+ assertions_failed = int(postman.get('assertions_failed', 0))
54
+ col_passed = int(postman.get('collections_passed', 0))
55
+ col_failed = int(postman.get('collections_failed', 0))
56
+ if assertions_total > 0:
57
+ pass_status = 'pass' if assertions_failed == 0 else 'fail'
58
+ else:
59
+ pass_status = 'unknown'
60
+
61
+ pass_rate = {
62
+ 'collectionsTotal': col_passed + col_failed,
63
+ 'collectionsPassed': col_passed,
64
+ 'assertionsTotal': assertions_total,
65
+ 'assertionsPassed': assertions_passed,
66
+ 'assertionsFailed': assertions_failed,
67
+ 'status': pass_status,
68
+ }
69
+
70
+ # --- coverage (API coverage) ---
71
+ if api_coverage:
72
+ cov_pct = float(api_coverage.get('coverage_percent', 0))
73
+ well_pct = float(api_coverage.get('well_tested_percent', 0))
74
+ qp_pct = float(api_coverage.get('query_params_percent', 0))
75
+ skipped = int(api_coverage.get('skipped_tests', 0))
76
+ gaps = (api_coverage.get('gaps') or [])[:25]
77
+ by_method = api_coverage.get('by_method') or {}
78
+ else:
79
+ cov_pct = well_pct = qp_pct = 0.0
80
+ skipped = 0
81
+ gaps = []
82
+ by_method = {}
83
+
84
+ coverage = {
85
+ 'coveragePercent': cov_pct,
86
+ 'wellTestedPercent': well_pct,
87
+ 'queryParamsPercent': qp_pct,
88
+ 'skippedTests': skipped,
89
+ 'gaps': gaps,
90
+ 'byMethod': by_method,
91
+ }
92
+
93
+ # --- mutation ---
94
+ mut_row = None
95
+ if mutation:
96
+ killed = int(mutation.get('killed', 0))
97
+ survived = int(mutation.get('survived', 0))
98
+ # PIT uses no_coverage; normalize to noCoverage
99
+ no_cov = int(mutation.get('noCoverage', mutation.get('no_coverage', 0)))
100
+ timeout = int(mutation.get('timeout', 0))
101
+ total = int(mutation.get('total', 0))
102
+ denominator = killed + survived + no_cov
103
+ score = round((killed / denominator * 100), 2) if denominator > 0 else 0.0
104
+ mut_row = {
105
+ 'tool': meta.get('mutation_tool', 'pit'),
106
+ 'killed': killed,
107
+ 'survived': survived,
108
+ 'noCoverage': no_cov,
109
+ 'timeout': timeout,
110
+ 'total': total,
111
+ 'score': score,
112
+ }
113
+
114
+ # --- failures (top 25 failing collections) ---
115
+ failures = [
116
+ {
117
+ 'suite': col.get('name', ''),
118
+ 'test': '',
119
+ 'reason': f"{col.get('assertions_failed', 0)} assertion(s) failed",
120
+ }
121
+ for col in postman_collections
122
+ if int(col.get('assertions_failed', 0)) > 0
123
+ ][:25]
124
+
125
+ # --- suites (per-collection breakdown) ---
126
+ suites = [
127
+ {
128
+ 'name': col.get('name', ''),
129
+ 'assertionsTotal': int(col.get('assertions_total', 0)),
130
+ 'assertionsPassed': int(col.get('assertions_passed', 0)),
131
+ 'coveragePercent': 0,
132
+ }
133
+ for col in postman_collections
134
+ ]
135
+
136
+ repo = _normalize_repo(meta.get('git_repo', ''))
137
+
138
+ row = {
139
+ 'Repo': repo,
140
+ 'DateTime': meta.get('datetime', ''),
141
+ 'displayName': meta.get('display_name', ''),
142
+ 'stack': meta.get('stack', ''),
143
+ 'environment': meta.get('environment', ''),
144
+ 'prNumber': meta.get('pr_number', ''),
145
+ 'commitSha': meta.get('git_commit', ''),
146
+ 'passRate': pass_rate,
147
+ 'coverage': coverage,
148
+ 'failures': failures,
149
+ 'suites': suites,
150
+ }
151
+ if mut_row:
152
+ row['mutation'] = mut_row
153
+ products = meta.get('products') or []
154
+ if products:
155
+ row['products'] = products
156
+
157
+ return row
158
+
159
+
160
+ def publish(row):
161
+ """Write row to DynamoDB. Gated by SHIFTLEFT_PUBLISH_METRICS=true. Never fails the build."""
162
+ flag = os.environ.get('SHIFTLEFT_PUBLISH_METRICS', '').strip().lower()
163
+ if flag not in ('true', '1', 'yes'):
164
+ return
165
+ table_name = os.environ.get('SHIFTLEFT_HEALTH_TABLE', 'mp-shiftleft-health')
166
+ if not table_name:
167
+ return
168
+ if not row.get('Repo') or not row.get('DateTime'):
169
+ print('[publish_health] Warning: Repo or DateTime missing — skipping publish.')
170
+ return
171
+ try:
172
+ import boto3
173
+ dynamodb = boto3.resource('dynamodb')
174
+ table = dynamodb.Table(table_name)
175
+ table.put_item(Item=row)
176
+ print(f'[publish_health] Published health row: {row["Repo"]} @ {row["DateTime"]}')
177
+ except Exception as exc:
178
+ print(f'[publish_health] Warning: publish failed (non-fatal): {exc}')
@@ -13,6 +13,30 @@ from report_utils import (
13
13
  from report_unit import parse_surefire_reports, parse_jacoco_report, parse_mutation_data
14
14
 
15
15
 
16
+ def _parse_stryker_json(path):
17
+ """Parse Stryker mutation-report.json into the same shape as parse_mutation_data."""
18
+ killed = survived = no_coverage = timeout = 0
19
+ try:
20
+ with open(path, 'r') as f:
21
+ data = json.load(f)
22
+ for fdata in data.get('files', {}).values():
23
+ for m in fdata.get('mutants', []):
24
+ st = m.get('status', '')
25
+ if st == 'Killed':
26
+ killed += 1
27
+ elif st == 'Survived':
28
+ survived += 1
29
+ elif st == 'NoCoverage':
30
+ no_coverage += 1
31
+ elif st == 'Timeout':
32
+ timeout += 1
33
+ except Exception as e:
34
+ print(f'Warning: Could not parse stryker JSON {path}: {e}')
35
+ total = killed + survived + no_coverage + timeout
36
+ return {'killed': killed, 'survived': survived, 'no_coverage': no_coverage, 'timeout': timeout, 'total': total,
37
+ 'mutator_stats': {}, 'survived_mutations': []}
38
+
39
+
16
40
  def generate_combined_report(args):
17
41
  output_html = args.output_file
18
42
 
@@ -20,6 +44,11 @@ def generate_combined_report(args):
20
44
  jacoco = parse_jacoco_report(args.jacoco_xml) if args.jacoco_xml and os.path.isfile(args.jacoco_xml) else None
21
45
  mutation = parse_mutation_data(args.mutations_xml) if args.mutations_xml and os.path.isfile(args.mutations_xml) else None
22
46
 
47
+ # Node stack: Stryker mutation report (--stryker-json)
48
+ stryker_json_path = getattr(args, 'stryker_json', None)
49
+ if mutation is None and stryker_json_path and os.path.isfile(stryker_json_path):
50
+ mutation = _parse_stryker_json(stryker_json_path)
51
+
23
52
  api_coverage = None
24
53
  if args.api_coverage_file and os.path.isfile(args.api_coverage_file):
25
54
  with open(args.api_coverage_file, 'r') as f:
@@ -178,6 +207,39 @@ def generate_combined_report(args):
178
207
  ])
179
208
 
180
209
  postman_collections = _load_postman_collections(args)
210
+
211
+ # Publish normalized health row to DynamoDB (gated by SHIFTLEFT_PUBLISH_METRICS env var)
212
+ try:
213
+ import datetime as _dt
214
+ from publish_health import build_row, publish
215
+ _mut_tool = 'stryker' if getattr(args, 'stryker_json', None) else 'pit'
216
+ _metrics = {
217
+ 'mutation': mutation,
218
+ 'api_coverage': api_coverage,
219
+ 'postman': {
220
+ 'assertions_total': postman_total,
221
+ 'assertions_passed': postman_passed,
222
+ 'assertions_failed': postman_failed,
223
+ 'collections_passed': postman_col_passed,
224
+ 'collections_failed': postman_col_failed,
225
+ },
226
+ 'postman_collections': postman_collections,
227
+ }
228
+ _meta = {
229
+ 'git_repo': getattr(args, 'git_repo', '') or '',
230
+ 'git_commit': getattr(args, 'git_commit', '') or '',
231
+ 'git_branch': getattr(args, 'git_branch', '') or '',
232
+ 'pr_number': getattr(args, 'pr_number', '') or '',
233
+ 'datetime': _dt.datetime.utcnow().isoformat() + 'Z',
234
+ 'display_name': getattr(args, 'logo', ''),
235
+ 'stack': getattr(args, 'stack', '') or '',
236
+ 'environment': getattr(args, 'env', '') or '',
237
+ 'mutation_tool': _mut_tool,
238
+ }
239
+ publish(build_row(_metrics, _meta))
240
+ except Exception as _e:
241
+ print(f'[health] Warning: publish health failed (non-fatal): {_e}')
242
+
181
243
  products_present = sorted(set(c['product'] for c in postman_collections))
182
244
  real_products = [p for p in products_present if p and p != 'default']
183
245
  is_multi_product = len(real_products) > 1
@@ -98,6 +98,16 @@ def main():
98
98
  comb.add_argument('--postman-consolidated-html')
99
99
  comb.add_argument('--env', default=None)
100
100
  comb.add_argument('--timestamp', default=None)
101
+ # Node-stack unit/mutation inputs
102
+ comb.add_argument('--stryker-json')
103
+ comb.add_argument('--mocha-xml')
104
+ comb.add_argument('--lcov')
105
+ # Run context for health publishing
106
+ comb.add_argument('--git-repo', default='')
107
+ comb.add_argument('--git-commit', default='')
108
+ comb.add_argument('--git-branch', default='')
109
+ comb.add_argument('--pr-number', default='')
110
+ comb.add_argument('--stack', default='')
101
111
 
102
112
  args = parser.parse_args()
103
113
 
@@ -51,6 +51,13 @@ REPORTS_DIR="$POSTMAN_DIR/reports"
51
51
  APP_PORT="${APP_PORT:-9090}"
52
52
  TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
53
53
 
54
+ # Git / CI run context for health publishing
55
+ GIT_COMMIT=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown")
56
+ GIT_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
57
+ GIT_REPO=$(git -C "$PROJECT_ROOT" config --get remote.origin.url 2>/dev/null || echo "unknown")
58
+ PR_NUMBER="${CHANGE_ID:-${GITHUB_PR_NUMBER:-}}"
59
+ export GIT_COMMIT GIT_BRANCH GIT_REPO PR_NUMBER
60
+
54
61
  # Environment selection (local or staging)
55
62
  POSTMAN_ENV="${POSTMAN_ENV:-local}"
56
63
 
@@ -369,7 +376,9 @@ if [ "$SKIP_REPORT" = false ]; then
369
376
  echo -e "${YELLOW}Generating combined report...${NC}"
370
377
 
371
378
  # Build report arguments as an array to handle spaces properly
372
- REPORT_ARGS=(combined "$COMBINED_OUTPUT" --logo "$REPORT_LOGO" --env "$POSTMAN_ENV" --timestamp "$TIMESTAMP")
379
+ REPORT_ARGS=(combined "$COMBINED_OUTPUT" --logo "$REPORT_LOGO" --env "$POSTMAN_ENV" --timestamp "$TIMESTAMP" --stack "Java")
380
+ REPORT_ARGS+=(--git-repo "$GIT_REPO" --git-commit "$GIT_COMMIT" --git-branch "$GIT_BRANCH")
381
+ [ -n "${PR_NUMBER:-}" ] && REPORT_ARGS+=(--pr-number "$PR_NUMBER")
373
382
 
374
383
  # Unit test data (use preserved unit test JaCoCo, not the postman one)
375
384
  [ -d "$SUREFIRE_DIR" ] && REPORT_ARGS+=(--surefire-dir "$SUREFIRE_DIR")
@@ -1 +1,2 @@
1
1
  defusedxml>=0.7.1
2
+ boto3>=1.28.0
@@ -33,6 +33,13 @@ source "$SCRIPT_DIR/lib/cleanup-stryker.sh"
33
33
  REPORTS_DIR="$POSTMAN_DIR/reports"
34
34
  TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
35
35
 
36
+ # Git / CI run context for health publishing
37
+ GIT_COMMIT=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown")
38
+ GIT_BRANCH=$(git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
39
+ GIT_REPO=$(git -C "$PROJECT_ROOT" config --get remote.origin.url 2>/dev/null || echo "unknown")
40
+ PR_NUMBER="${CHANGE_ID:-${GITHUB_PR_NUMBER:-}}"
41
+ export GIT_COMMIT GIT_BRANCH GIT_REPO PR_NUMBER
42
+
36
43
  POSTMAN_ENV="${POSTMAN_ENV:-local}"
37
44
  SKIP_UNIT=false
38
45
  SKIP_MUTATION=false
@@ -246,7 +253,9 @@ if [ "$SKIP_REPORT" = false ]; then
246
253
 
247
254
  "$SCRIPT_DIR/report-generators/stage-report-artifacts.sh" "$PROJECT_ROOT" "$REPORTS_DIR"
248
255
 
249
- REPORT_ARGS=(combined "$COMBINED_OUTPUT" --logo "API Service")
256
+ REPORT_ARGS=(combined "$COMBINED_OUTPUT" --logo "API Service" --stack "NodeJS")
257
+ REPORT_ARGS+=(--git-repo "$GIT_REPO" --git-commit "$GIT_COMMIT" --git-branch "$GIT_BRANCH")
258
+ [ -n "${PR_NUMBER:-}" ] && REPORT_ARGS+=(--pr-number "$PR_NUMBER")
250
259
 
251
260
  [ -f "$MOCHA_XML" ] && REPORT_ARGS+=(--mocha-xml "$MOCHA_XML")
252
261
  [ -f "$LCOV" ] && REPORT_ARGS+=(--lcov "$LCOV")