norn-cli 2.3.0 → 2.5.0

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/AGENTS.md CHANGED
@@ -63,8 +63,8 @@ Skills should capture lessons learned and patterns discovered during implementat
63
63
  - no docs changes were needed because the release is only bug fixes, refactors, or design/styling work
64
64
 
65
65
  The Test Verification agent runs:
66
- - `npm run compile` (must have 0 errors)
67
- - `node ./dist/cli.js ./tests/Regression/ -e prelive` (all tests must pass)
66
+ - `npm test` (must pass compile/lint, Extension Host editor diagnostics, Test Explorer support, and runtime-negative automation)
67
+ - `npm run test:regression` (local CLI Regression suite; all tests must pass)
68
68
 
69
69
  The Website Documentation Review agent:
70
70
  - reviews release changes in `/Users/petercrest/Worktable/Projects/vsApi`
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to the "Norn" extension will be documented in this file.
4
4
 
5
+ ## [2.5.0] - 2026-05-27
6
+
7
+ ### Added
8
+ - **Verbatim string literals** — added C#-style `@"..."` strings for Norn values, assertions, regex patterns, request URLs, sequence arguments/defaults, SQL arguments, schema paths, assertion messages, and `@data` values. Backslashes are literal, doubled quotes decode to a single quote, and `{{...}}` tokens inside verbatim strings are not interpolated.
9
+ - **Escape-aware highlighting** — `.norn` syntax highlighting now distinguishes decoded escapes from ordinary backslash text, including escaped quotes/backslashes in normal strings, regex escapes in `matches` patterns, doubled quotes in verbatim strings, and valid JSON escapes in request bodies.
10
+
11
+ ### Changed
12
+ - **Regex assertion authoring** — `matches` patterns are now guided toward quoted string patterns, with `@"..."` recommended for regexes that contain many backslashes or literal quotes.
13
+
14
+ ### Fixed
15
+ - **Quoted string handling** — escaped quotes and backslashes are decoded consistently across variables, request URLs, run arguments, sequence defaults, SQL arguments, `@data` values, `matchesSchema` paths, and assertion messages while preserving unknown escapes such as `\A` as literal text.
16
+ - **Editor assistance with verbatim strings** — diagnostics, inlay hints, and hover providers no longer treat `{{...}}` inside verbatim strings as interpolated Norn variables.
17
+ - **Sequence tag diagnostics** — `@smoke`, `@team(...)`, and other sequence tags are recognized by editor diagnostics again instead of being reported as unknown statements.
18
+ - **Failure output expected values** — CLI, HTML/JUnit reports, and the response panel now prefer the resolved expected value for failed assertions instead of falling back to the raw expression text.
19
+ - **Test Explorer environment resolution** — Test Explorer now uses the only available `.nornenv` environment for isolated single-env fixtures when no active environment has been selected, fixing nested regression cases such as encrypted env imports.
20
+
21
+ ### Testing
22
+ - **Editor diagnostics automation** — added an Extension Host suite that discovers `tests/EditorDiagnostics/valid` and `tests/EditorDiagnostics/negative`, keeping clean fixtures at zero diagnostics and intentional error fixtures at exact expected counts.
23
+ - **Runtime negative automation** — converted former manual runtime error cases into `tests/RuntimeNegative` scenarios that assert CLI exit codes and stable error guidance.
24
+ - **Test Explorer automation** — added Extension Host checks for Test Explorer fixture exclusion patterns and single-env `.nornenv` resolution.
25
+ - **Pre-release verification** — added `npm run test:prerelease` and updated release verification to run Extension Host automation plus the local CLI Regression suite.
26
+ - **Test suite cleanup** — moved reusable `.nornenv` import and contract fixtures into Regression, moved API coverage fixtures into `tests/ApiCoverage`, and removed stale scratch/demo test folders.
27
+
28
+ ## [2.4.0] - 2026-05-25
29
+
30
+ ### Added
31
+ - **Per-folder and persisted active environment** — the active env is now tracked per `.nornenv` file (keyed by absolute path) and persisted to the workspace memento. In monorepos each folder remembers its own selection, and selections survive VS Code restart. The status bar shows the env for the current editor's nearest `.nornenv`. Stale entries (deleted `.nornenv` files) are pruned on load.
32
+ - **Region-pattern refactor** — when a `.nornenv` contains a flat `[env:STAGE_REGION]` matrix (e.g. `dev_us`, `dev_uk`, `prod_us`, `prod_uk`), the top of the file shows a "Refactor N envs into S+R templates" CodeLens. Clicking it classifies each variable by axis (stage / region / leaf-specific) and lifts shared values into `[template:STAGE]` + `[template:REGION]` blocks, leaving only true leaf-specific overrides in the env sections. Missing values and `connectionString` declarations stay leaf-specific, per-declaration `secret` keywords are preserved, and the same generator is available in the CLI via `--refactor-region-pattern` / `--write`. The VS Code refactor is one `WorkspaceEdit` so a single Cmd+Z reverses it.
33
+
5
34
  ## [2.3.0] - 2026-05-20
6
35
 
7
36
  ### Added
@@ -422,7 +451,7 @@ All notable changes to the "Norn" extension will be documented in this file.
422
451
 
423
452
  ### Added
424
453
  - **Regression Fixture (Coverage Scope)**:
425
- - Added `tests/Random/coverage-scope/template-version.nornapi` and `tests/Random/coverage-scope/template-version-covered.norn` to lock in template-path coverage behavior.
454
+ - Added `tests/ApiCoverage/coverage-scope/template-version.nornapi` and `tests/ApiCoverage/coverage-scope/template-version-covered.norn` to lock in template-path coverage behavior.
426
455
 
427
456
  ## [1.4.1] - 2026-02-21
428
457
 
@@ -0,0 +1,64 @@
1
+ # Region-pattern refactor showcase
2
+
3
+ A flat `[env:STAGE_REGION]` matrix that the Norn extension can refactor into the templates + extends shape in one click.
4
+
5
+ ## How to use
6
+
7
+ 1. Open [.nornenv](./.nornenv) in VS Code.
8
+ 2. A `$(sparkle) Refactor 4 envs into 2+2 templates` CodeLens appears at the top of the file.
9
+ 3. Click it. A confirmation dialog summarises what will change:
10
+ - 2 vars (`baseUrl`, `apiKey`) lifted to stage templates (`dev`, `prod`)
11
+ - 2 vars (`dbHost`, `bucket`) lifted to region templates (`us`, `uk`)
12
+ - 1 var (`failoverHost`) kept as leaf-specific on `prod_uk`
13
+ 4. Confirm. The flat 4-env matrix is replaced with templates + extending envs:
14
+
15
+ ```nornenv
16
+ [template:dev]
17
+ var baseUrl = https://dev.example.com
18
+ var apiKey = dev-key-123
19
+
20
+ [template:prod]
21
+ var baseUrl = https://api.example.com
22
+ secret apiKey = prod-key-789
23
+
24
+ [template:us]
25
+ var dbHost = db.us.example.com
26
+ var bucket = data-us
27
+
28
+ [template:uk]
29
+ var dbHost = db.uk.example.com
30
+ var bucket = data-uk
31
+
32
+ [env:dev_us extends dev, us]
33
+
34
+ [env:dev_uk extends dev, uk]
35
+
36
+ [env:prod_us extends prod, us]
37
+
38
+ [env:prod_uk extends prod, uk]
39
+ var failoverHost = api-failover.uk.example.com
40
+ ```
41
+
42
+ 5. The `secret` keyword on `apiKey` is preserved when the var is lifted to a template.
43
+
44
+ ## CLI
45
+
46
+ The same generator is available from the local CLI:
47
+
48
+ ```bash
49
+ node ./dist/cli.js demos/nornenv-region-refactor/.nornenv --refactor-region-pattern
50
+ node ./dist/cli.js demos/nornenv-region-refactor/.nornenv --refactor-region-pattern --write
51
+ ```
52
+
53
+ ## When the refactor fires
54
+
55
+ The CodeLens appears only when **all** of these are true:
56
+ - At least 2 stages × 2 regions
57
+ - At least 3 populated cells
58
+ - All matrix envs are flat (no existing `extends` clause)
59
+
60
+ It does not touch envs that already use `extends`, envs with names that don't match the `STAGE_REGION` shape, or `connectionString` declarations (those are left in their leaves — refactor manually).
61
+
62
+ ## Cmd+Z
63
+
64
+ Everything goes through a single `WorkspaceEdit`, so a single Cmd+Z undoes the whole refactor.
package/dist/cli.js CHANGED
@@ -101832,6 +101832,119 @@ var path19 = __toESM(require("path"));
101832
101832
  // src/parser.ts
101833
101833
  var path = __toESM(require("path"));
101834
101834
 
101835
+ // src/quotedString.ts
101836
+ function decodeQuotedStringLiteral(literal2) {
101837
+ if (isVerbatimStringLiteral(literal2)) {
101838
+ return decodeVerbatimStringLiteral(literal2);
101839
+ }
101840
+ if (!isQuotedStringLiteral(literal2)) {
101841
+ return literal2;
101842
+ }
101843
+ const quoteChar = literal2[0];
101844
+ const inner = literal2.slice(1, -1);
101845
+ let decoded = "";
101846
+ for (let i = 0; i < inner.length; i++) {
101847
+ const char = inner[i];
101848
+ if (char !== "\\" || i === inner.length - 1) {
101849
+ decoded += char;
101850
+ continue;
101851
+ }
101852
+ const nextChar = inner[i + 1];
101853
+ if (nextChar === "\\" || nextChar === quoteChar) {
101854
+ decoded += nextChar;
101855
+ i++;
101856
+ continue;
101857
+ }
101858
+ decoded += char;
101859
+ }
101860
+ return decoded;
101861
+ }
101862
+ function isQuotedStringLiteral(literal2) {
101863
+ if (isVerbatimStringLiteral(literal2)) {
101864
+ return true;
101865
+ }
101866
+ if (literal2.length < 2) {
101867
+ return false;
101868
+ }
101869
+ const quoteChar = literal2[0];
101870
+ return (quoteChar === '"' || quoteChar === "'") && literal2[literal2.length - 1] === quoteChar;
101871
+ }
101872
+ function isVerbatimStringLiteral(literal2) {
101873
+ return literal2.length >= 3 && literal2.startsWith('@"') && literal2.endsWith('"');
101874
+ }
101875
+ function mapOutsideVerbatimStrings(text, transform2) {
101876
+ let output2 = "";
101877
+ let segmentStart = 0;
101878
+ let inStandardQuote = false;
101879
+ let standardQuoteChar = "";
101880
+ let escapeNext = false;
101881
+ for (let i = 0; i < text.length; i++) {
101882
+ const char = text[i];
101883
+ const nextChar = i + 1 < text.length ? text[i + 1] : "";
101884
+ if (inStandardQuote) {
101885
+ if (escapeNext) {
101886
+ escapeNext = false;
101887
+ continue;
101888
+ }
101889
+ if (char === "\\") {
101890
+ escapeNext = true;
101891
+ continue;
101892
+ }
101893
+ if (char === standardQuoteChar) {
101894
+ inStandardQuote = false;
101895
+ standardQuoteChar = "";
101896
+ }
101897
+ continue;
101898
+ }
101899
+ if (char === "@" && nextChar === '"') {
101900
+ const end = findVerbatimStringEnd(text, i);
101901
+ output2 += transform2(text.substring(segmentStart, i));
101902
+ output2 += text.substring(i, end);
101903
+ segmentStart = end;
101904
+ i = end - 1;
101905
+ continue;
101906
+ }
101907
+ if (char === '"' || char === "'") {
101908
+ inStandardQuote = true;
101909
+ standardQuoteChar = char;
101910
+ escapeNext = false;
101911
+ }
101912
+ }
101913
+ output2 += transform2(text.substring(segmentStart));
101914
+ return output2;
101915
+ }
101916
+ function findVerbatimStringEnd(text, start) {
101917
+ let i = start + 2;
101918
+ while (i < text.length) {
101919
+ const char = text[i];
101920
+ const nextChar = i + 1 < text.length ? text[i + 1] : "";
101921
+ if (char === '"' && nextChar === '"') {
101922
+ i += 2;
101923
+ continue;
101924
+ }
101925
+ if (char === '"') {
101926
+ return i + 1;
101927
+ }
101928
+ i++;
101929
+ }
101930
+ return text.length;
101931
+ }
101932
+ function decodeVerbatimStringLiteral(literal2) {
101933
+ const inner = literal2.slice(2, -1);
101934
+ let decoded = "";
101935
+ for (let i = 0; i < inner.length; i++) {
101936
+ const char = inner[i];
101937
+ const nextChar = i + 1 < inner.length ? inner[i + 1] : "";
101938
+ if (char === '"' && nextChar === '"') {
101939
+ decoded += '"';
101940
+ i++;
101941
+ continue;
101942
+ }
101943
+ decoded += char;
101944
+ }
101945
+ return decoded;
101946
+ }
101947
+
101835
101948
  // src/nornapiParser.ts
101836
101949
  function extractPathParameters(path20) {
101837
101950
  const params = [];
@@ -102003,8 +102116,8 @@ function parseApiRequest(requestContent, endpoints, headerGroups) {
102003
102116
  const headerMatch = line2.match(/^([a-zA-Z][a-zA-Z0-9\-]*)\s*:\s*(.+)$/);
102004
102117
  if (headerMatch) {
102005
102118
  let headerValue = headerMatch[2].trim();
102006
- if (headerValue.startsWith('"') && headerValue.endsWith('"') || headerValue.startsWith("'") && headerValue.endsWith("'")) {
102007
- headerValue = headerValue.slice(1, -1);
102119
+ if (isQuotedStringLiteral(headerValue)) {
102120
+ headerValue = decodeQuotedStringLiteral(headerValue);
102008
102121
  }
102009
102122
  inlineHeaders[headerMatch[1]] = headerValue;
102010
102123
  continue;
@@ -102049,8 +102162,8 @@ function parseParamTokens(paramsStr) {
102049
102162
  return tokens;
102050
102163
  }
102051
102164
  function unquote(value) {
102052
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
102053
- return value.slice(1, -1);
102165
+ if (isQuotedStringLiteral(value)) {
102166
+ return decodeQuotedStringLiteral(value);
102054
102167
  }
102055
102168
  return value;
102056
102169
  }
@@ -102367,14 +102480,21 @@ function getNestedPathValue(obj, path20) {
102367
102480
  function stripInlineComment(line2) {
102368
102481
  let inSingleQuote = false;
102369
102482
  let inDoubleQuote = false;
102483
+ let inVerbatimString = false;
102370
102484
  for (let index = 0; index < line2.length; index++) {
102371
102485
  const char = line2[index];
102372
102486
  const previousChar = index > 0 ? line2[index - 1] : "";
102373
- if (previousChar === "\\") {
102487
+ const nextChar = index + 1 < line2.length ? line2[index + 1] : "";
102488
+ if (inDoubleQuote && inVerbatimString && char === '"' && nextChar === '"') {
102489
+ index++;
102490
+ continue;
102491
+ }
102492
+ if (!inVerbatimString && previousChar === "\\") {
102374
102493
  continue;
102375
102494
  }
102376
102495
  if (char === '"' && !inSingleQuote) {
102377
102496
  inDoubleQuote = !inDoubleQuote;
102497
+ inVerbatimString = inDoubleQuote && previousChar === "@";
102378
102498
  } else if (char === "'" && !inDoubleQuote) {
102379
102499
  inSingleQuote = !inSingleQuote;
102380
102500
  } else if (char === "#" && !inSingleQuote && !inDoubleQuote) {
@@ -102491,8 +102611,8 @@ function extractFileLevelVariables(text) {
102491
102611
  if (isRuntimeComputedVariableValue(value)) {
102492
102612
  continue;
102493
102613
  }
102494
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
102495
- value = value.slice(1, -1);
102614
+ if (isQuotedStringLiteral(value)) {
102615
+ value = decodeQuotedStringLiteral(value);
102496
102616
  }
102497
102617
  variables[match[1]] = value;
102498
102618
  }
@@ -102598,8 +102718,8 @@ function parserHttpRequest(text, variables = {}) {
102598
102718
  requestLine = cleanedLine;
102599
102719
  const [method, ...urlParts] = requestLine.split(" ");
102600
102720
  let url3 = urlParts.join(" ");
102601
- if (url3.startsWith('"') && url3.endsWith('"') || url3.startsWith("'") && url3.endsWith("'")) {
102602
- url3 = url3.slice(1, -1);
102721
+ if (isQuotedStringLiteral(url3)) {
102722
+ url3 = decodeQuotedStringLiteral(url3);
102603
102723
  }
102604
102724
  const headers = {};
102605
102725
  let bodyStartIndex = -1;
@@ -109661,34 +109781,6 @@ function validateAgainstSchemaObjectDetailed(value, schema) {
109661
109781
  }
109662
109782
  }
109663
109783
 
109664
- // src/quotedString.ts
109665
- function decodeQuotedStringLiteral(literal2) {
109666
- if (literal2.length < 2) {
109667
- return literal2;
109668
- }
109669
- const quoteChar = literal2[0];
109670
- if (quoteChar !== '"' && quoteChar !== "'" || literal2[literal2.length - 1] !== quoteChar) {
109671
- return literal2;
109672
- }
109673
- const inner = literal2.slice(1, -1);
109674
- let decoded = "";
109675
- for (let i = 0; i < inner.length; i++) {
109676
- const char = inner[i];
109677
- if (char !== "\\" || i === inner.length - 1) {
109678
- decoded += char;
109679
- continue;
109680
- }
109681
- const nextChar = inner[i + 1];
109682
- if (nextChar === "\\" || nextChar === quoteChar) {
109683
- decoded += nextChar;
109684
- i++;
109685
- continue;
109686
- }
109687
- decoded += char;
109688
- }
109689
- return decoded;
109690
- }
109691
-
109692
109784
  // src/assertionRunner.ts
109693
109785
  function isAssertCommand(line2) {
109694
109786
  return /^assert\s+/i.test(line2.trim());
@@ -109704,17 +109796,25 @@ function parseAssertCommand(line2) {
109704
109796
  const pipeIndex = findUnquotedPipe(content);
109705
109797
  if (pipeIndex !== -1) {
109706
109798
  message = content.substring(pipeIndex + 1).trim();
109707
- if (message.startsWith('"') && message.endsWith('"') || message.startsWith("'") && message.endsWith("'")) {
109799
+ if (isQuotedStringLiteral(message)) {
109800
+ const messageWasVerbatim = isVerbatimStringLiteral(message);
109708
109801
  message = decodeQuotedStringLiteral(message);
109802
+ if (messageWasVerbatim) {
109803
+ return parseAssertContent(content.substring(0, pipeIndex).trim(), message, true);
109804
+ }
109709
109805
  }
109710
109806
  content = content.substring(0, pipeIndex).trim();
109711
109807
  }
109808
+ return parseAssertContent(content, message, false);
109809
+ }
109810
+ function parseAssertContent(content, message, messageIsVerbatim) {
109712
109811
  const existsMatch = content.match(/^(.+?)\s+(exists|!exists)$/i);
109713
109812
  if (existsMatch) {
109714
109813
  return {
109715
109814
  leftExpr: existsMatch[1].trim(),
109716
109815
  operator: existsMatch[2].toLowerCase(),
109717
- message
109816
+ message,
109817
+ messageIsVerbatim
109718
109818
  };
109719
109819
  }
109720
109820
  const binaryOperators = [
@@ -109738,7 +109838,8 @@ function parseAssertCommand(line2) {
109738
109838
  leftExpr: binaryMatch[1].trim(),
109739
109839
  operator: op,
109740
109840
  rightExpr: binaryMatch[2].trim(),
109741
- message
109841
+ message,
109842
+ messageIsVerbatim
109742
109843
  };
109743
109844
  }
109744
109845
  }
@@ -109748,22 +109849,29 @@ function findUnquotedPipe(str) {
109748
109849
  let inQuote = false;
109749
109850
  let quoteChar = "";
109750
109851
  let escapeNext = false;
109852
+ let inVerbatimString = false;
109751
109853
  for (let i = 0; i < str.length; i++) {
109752
109854
  const char = str[i];
109855
+ if (inQuote && inVerbatimString && char === '"' && str[i + 1] === '"') {
109856
+ i++;
109857
+ continue;
109858
+ }
109753
109859
  if (escapeNext) {
109754
109860
  escapeNext = false;
109755
109861
  continue;
109756
109862
  }
109757
- if (inQuote && char === "\\") {
109863
+ if (inQuote && !inVerbatimString && char === "\\") {
109758
109864
  escapeNext = true;
109759
109865
  continue;
109760
109866
  }
109761
109867
  if ((char === '"' || char === "'") && !inQuote) {
109762
109868
  inQuote = true;
109763
109869
  quoteChar = char;
109870
+ inVerbatimString = char === '"' && i > 0 && str[i - 1] === "@";
109764
109871
  } else if (char === quoteChar && inQuote) {
109765
109872
  inQuote = false;
109766
109873
  quoteChar = "";
109874
+ inVerbatimString = false;
109767
109875
  } else if (char === "|" && !inQuote) {
109768
109876
  return i;
109769
109877
  }
@@ -109875,8 +109983,9 @@ function resolveValue(expr, responses, variables, getValueByPath2, responseIndex
109875
109983
  }
109876
109984
  return { value: void 0, error: `Variable {{${varName}}} is not defined` };
109877
109985
  }
109878
- if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
109879
- return { value: substituteVariables(decodeQuotedStringLiteral(trimmed), variables) };
109986
+ if (isQuotedStringLiteral(trimmed)) {
109987
+ const decoded = decodeQuotedStringLiteral(trimmed);
109988
+ return { value: isVerbatimStringLiteral(trimmed) ? decoded : substituteVariables(decoded, variables) };
109880
109989
  }
109881
109990
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
109882
109991
  return { value: parseFloat(trimmed) };
@@ -109949,7 +110058,7 @@ function areValuesEqual(leftValue, rightValue) {
109949
110058
  }
109950
110059
  function evaluateAssertion(assertion, responses, variables, getValueByPath2, responseIndexToVariable, basePath) {
109951
110060
  const leftResult = resolveValue(assertion.leftExpr, responses, variables, getValueByPath2, responseIndexToVariable);
109952
- const message = assertion.message === void 0 ? void 0 : substituteVariables(assertion.message, variables);
110061
+ const message = assertion.message === void 0 ? void 0 : assertion.messageIsVerbatim ? assertion.message : substituteVariables(assertion.message, variables);
109953
110062
  const expression = formatExpression(assertion, variables);
109954
110063
  const buildFailureContext = () => ({
109955
110064
  responseIndex: leftResult.responseIndex,
@@ -110032,10 +110141,14 @@ function evaluateAssertion(assertion, responses, variables, getValueByPath2, res
110032
110141
  }
110033
110142
  if (assertion.operator === "matchesSchema") {
110034
110143
  let schemaPath = assertion.rightExpr;
110035
- if (schemaPath.startsWith('"') && schemaPath.endsWith('"') || schemaPath.startsWith("'") && schemaPath.endsWith("'")) {
110036
- schemaPath = schemaPath.slice(1, -1);
110144
+ let schemaPathIsVerbatim = false;
110145
+ if (isQuotedStringLiteral(schemaPath)) {
110146
+ schemaPathIsVerbatim = isVerbatimStringLiteral(schemaPath);
110147
+ schemaPath = decodeQuotedStringLiteral(schemaPath);
110148
+ }
110149
+ if (!schemaPathIsVerbatim) {
110150
+ schemaPath = substituteVariables(schemaPath, variables);
110037
110151
  }
110038
- schemaPath = substituteVariables(schemaPath, variables);
110039
110152
  const validationResult = validateAgainstSchemaDetailed(leftValue, schemaPath, basePath);
110040
110153
  const passed2 = validationResult.valid;
110041
110154
  return {
@@ -110102,15 +110215,8 @@ function evaluateAssertion(assertion, responses, variables, getValueByPath2, res
110102
110215
  break;
110103
110216
  case "matches":
110104
110217
  try {
110105
- let pattern = String(rightValue);
110106
- if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
110107
- const lastSlash = pattern.lastIndexOf("/");
110108
- const flags = pattern.substring(lastSlash + 1);
110109
- pattern = pattern.substring(1, lastSlash);
110110
- passed = new RegExp(pattern, flags).test(String(leftValue));
110111
- } else {
110112
- passed = new RegExp(pattern).test(String(leftValue));
110113
- }
110218
+ const pattern = String(rightValue);
110219
+ passed = new RegExp(pattern).test(String(leftValue));
110114
110220
  } catch (e) {
110115
110221
  return {
110116
110222
  passed: false,
@@ -110146,7 +110252,7 @@ function formatExpression(assertion, variables) {
110146
110252
  return substituteAssertionDisplayTemplates(`${assertion.leftExpr} ${assertion.operator}`, variables);
110147
110253
  }
110148
110254
  function substituteAssertionDisplayTemplates(expression, variables) {
110149
- return variables ? substituteVariables(expression, variables) : expression;
110255
+ return variables ? mapOutsideVerbatimStrings(expression, (segment) => substituteVariables(segment, variables)) : expression;
110150
110256
  }
110151
110257
 
110152
110258
  // src/jsonFileReader.ts
@@ -110163,8 +110269,8 @@ function parseJsonCommand(line2) {
110163
110269
  return null;
110164
110270
  }
110165
110271
  let filePath = match[2].trim();
110166
- if (filePath.startsWith('"') && filePath.endsWith('"') || filePath.startsWith("'") && filePath.endsWith("'")) {
110167
- filePath = filePath.slice(1, -1);
110272
+ if (isQuotedStringLiteral(filePath)) {
110273
+ filePath = decodeQuotedStringLiteral(filePath);
110168
110274
  }
110169
110275
  return {
110170
110276
  varName: match[1],
@@ -128016,7 +128122,7 @@ function parseRunArguments(argsStr) {
128016
128122
  if (!argsStr || !argsStr.trim()) {
128017
128123
  return args;
128018
128124
  }
128019
- const parts = argsStr.split(/,(?=(?:[^"]*"[^"]*")*(?:[^{]*\{\{[^}]*\}\})*[^"]*$)/);
128125
+ const parts = splitNamedArgumentList(argsStr);
128020
128126
  for (const part of parts) {
128021
128127
  const trimmed = part.trim();
128022
128128
  if (!trimmed) {
@@ -128025,7 +128131,7 @@ function parseRunArguments(argsStr) {
128025
128131
  const namedMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
128026
128132
  if (namedMatch) {
128027
128133
  let value = namedMatch[2].trim();
128028
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
128134
+ if (isQuotedStringLiteral(value)) {
128029
128135
  value = decodeQuotedStringLiteral(value);
128030
128136
  }
128031
128137
  args.push({
@@ -128034,7 +128140,7 @@ function parseRunArguments(argsStr) {
128034
128140
  });
128035
128141
  } else {
128036
128142
  let value = trimmed;
128037
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
128143
+ if (isQuotedStringLiteral(value)) {
128038
128144
  value = decodeQuotedStringLiteral(value);
128039
128145
  }
128040
128146
  args.push({ value });
@@ -128046,6 +128152,7 @@ function splitNamedArgumentList(argsStr) {
128046
128152
  const parts = [];
128047
128153
  let current = "";
128048
128154
  let quoteChar = null;
128155
+ let inVerbatimString = false;
128049
128156
  let templateDepth = 0;
128050
128157
  let bracketDepth = 0;
128051
128158
  for (let i = 0; i < argsStr.length; i++) {
@@ -128054,13 +128161,20 @@ function splitNamedArgumentList(argsStr) {
128054
128161
  const prev = i > 0 ? argsStr[i - 1] : "";
128055
128162
  if (quoteChar) {
128056
128163
  current += char;
128164
+ if (inVerbatimString && char === '"' && next === '"') {
128165
+ current += next;
128166
+ i++;
128167
+ continue;
128168
+ }
128057
128169
  if (char === quoteChar && prev !== "\\") {
128058
128170
  quoteChar = null;
128171
+ inVerbatimString = false;
128059
128172
  }
128060
128173
  continue;
128061
128174
  }
128062
128175
  if (char === '"' || char === "'") {
128063
128176
  quoteChar = char;
128177
+ inVerbatimString = char === '"' && prev === "@";
128064
128178
  current += char;
128065
128179
  continue;
128066
128180
  }
@@ -128520,7 +128634,7 @@ function parseSequenceParameters(line2) {
128520
128634
  if (!paramsStr) {
128521
128635
  return params;
128522
128636
  }
128523
- const paramParts = paramsStr.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/);
128637
+ const paramParts = splitNamedArgumentList(paramsStr);
128524
128638
  let hasSeenDefault = false;
128525
128639
  for (const part of paramParts) {
128526
128640
  const trimmed = part.trim();
@@ -128531,7 +128645,7 @@ function parseSequenceParameters(line2) {
128531
128645
  if (defaultMatch) {
128532
128646
  hasSeenDefault = true;
128533
128647
  let defaultValue = defaultMatch[2].trim();
128534
- if (defaultValue.startsWith('"') && defaultValue.endsWith('"') || defaultValue.startsWith("'") && defaultValue.endsWith("'")) {
128648
+ if (isQuotedStringLiteral(defaultValue)) {
128535
128649
  defaultValue = decodeQuotedStringLiteral(defaultValue);
128536
128650
  }
128537
128651
  params.push({
@@ -128608,14 +128722,21 @@ function parseDataValues(valuesStr) {
128608
128722
  const values = [];
128609
128723
  let current = "";
128610
128724
  let inQuote = null;
128725
+ let inVerbatimString = false;
128611
128726
  let i = 0;
128612
128727
  while (i < valuesStr.length) {
128613
128728
  const char = valuesStr[i];
128614
128729
  if (inQuote) {
128730
+ if (inVerbatimString && char === '"' && valuesStr[i + 1] === '"') {
128731
+ current += '""';
128732
+ i += 2;
128733
+ continue;
128734
+ }
128615
128735
  if (char === inQuote) {
128616
- values.push(current);
128736
+ values.push(inVerbatimString ? decodeQuotedStringLiteral(`@"${current}"`) : current);
128617
128737
  current = "";
128618
128738
  inQuote = null;
128739
+ inVerbatimString = false;
128619
128740
  i++;
128620
128741
  while (i < valuesStr.length && valuesStr[i] !== ",") {
128621
128742
  i++;
@@ -128626,8 +128747,15 @@ function parseDataValues(valuesStr) {
128626
128747
  current += char;
128627
128748
  }
128628
128749
  } else {
128629
- if (char === '"' || char === "'") {
128750
+ if (char === "@" && valuesStr[i + 1] === '"') {
128751
+ inQuote = '"';
128752
+ inVerbatimString = true;
128753
+ current = "";
128754
+ i += 2;
128755
+ continue;
128756
+ } else if (char === '"' || char === "'") {
128630
128757
  inQuote = char;
128758
+ inVerbatimString = false;
128631
128759
  current = "";
128632
128760
  } else if (char === ",") {
128633
128761
  const trimmed = current.trim();
@@ -128664,7 +128792,7 @@ function parseTypedValue(value) {
128664
128792
  if (/^-?\d+(\.\d+)?$/.test(value)) {
128665
128793
  return parseFloat(value);
128666
128794
  }
128667
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
128795
+ if (isQuotedStringLiteral(value)) {
128668
128796
  return decodeQuotedStringLiteral(value);
128669
128797
  }
128670
128798
  return value;
@@ -128790,9 +128918,9 @@ function parseVarAssignCommand(line2) {
128790
128918
  }
128791
128919
  function evaluateValueExpression(expr, runtimeVariables) {
128792
128920
  const trimmed = expr.trim();
128793
- if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
128921
+ if (isQuotedStringLiteral(trimmed)) {
128794
128922
  const inner = decodeQuotedStringLiteral(trimmed);
128795
- const substituted = substituteVariables(inner, runtimeVariables);
128923
+ const substituted = isVerbatimStringLiteral(trimmed) ? inner : substituteVariables(inner, runtimeVariables);
128796
128924
  return { value: substituted };
128797
128925
  }
128798
128926
  if (trimmed === "true" || trimmed === "false" || trimmed === "null") {
@@ -128840,9 +128968,9 @@ function evaluateSqlArgumentExpression(expr, runtimeVariables) {
128840
128968
  if (!trimmed) {
128841
128969
  return { value: "" };
128842
128970
  }
128843
- if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
128971
+ if (isQuotedStringLiteral(trimmed)) {
128844
128972
  const inner = decodeQuotedStringLiteral(trimmed);
128845
- return { value: substituteVariables(inner, runtimeVariables) };
128973
+ return { value: isVerbatimStringLiteral(trimmed) ? inner : substituteVariables(inner, runtimeVariables) };
128846
128974
  }
128847
128975
  if (trimmed.startsWith("{{") && trimmed.endsWith("}}")) {
128848
128976
  return evaluateSqlArgumentExpression(trimmed.slice(2, -2).trim(), runtimeVariables);
@@ -128952,7 +129080,7 @@ function resolveBareVariables(text, variables) {
128952
129080
  const parts = splitExpressionParts(text);
128953
129081
  const resolvedParts = parts.map((part) => {
128954
129082
  const partTrimmed = part.trim();
128955
- if (partTrimmed.startsWith('"') && partTrimmed.endsWith('"') || partTrimmed.startsWith("'") && partTrimmed.endsWith("'")) {
129083
+ if (isQuotedStringLiteral(partTrimmed)) {
128956
129084
  return decodeQuotedStringLiteral(partTrimmed);
128957
129085
  }
128958
129086
  const varMatch = partTrimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)$/);
@@ -128980,6 +129108,7 @@ function splitExpressionParts(expr) {
128980
129108
  let inString = false;
128981
129109
  let stringChar = "";
128982
129110
  let escapeNext = false;
129111
+ let inVerbatimString = false;
128983
129112
  for (let i = 0; i < expr.length; i++) {
128984
129113
  const char = expr[i];
128985
129114
  if (escapeNext) {
@@ -128987,7 +129116,12 @@ function splitExpressionParts(expr) {
128987
129116
  escapeNext = false;
128988
129117
  continue;
128989
129118
  }
128990
- if (inString && char === "\\") {
129119
+ if (inString && inVerbatimString && char === '"' && i + 1 < expr.length && expr[i + 1] === '"') {
129120
+ current += char + expr[i + 1];
129121
+ i++;
129122
+ continue;
129123
+ }
129124
+ if (inString && !inVerbatimString && char === "\\") {
128991
129125
  current += char;
128992
129126
  escapeNext = true;
128993
129127
  continue;
@@ -128995,9 +129129,11 @@ function splitExpressionParts(expr) {
128995
129129
  if (!inString && (char === '"' || char === "'")) {
128996
129130
  inString = true;
128997
129131
  stringChar = char;
129132
+ inVerbatimString = char === '"' && i > 0 && expr[i - 1] === "@";
128998
129133
  current += char;
128999
129134
  } else if (inString && char === stringChar) {
129000
129135
  inString = false;
129136
+ inVerbatimString = false;
129001
129137
  current += char;
129002
129138
  } else if (!inString && char === "+") {
129003
129139
  parts.push(current);
@@ -131444,7 +131580,8 @@ function formatAssertion(assertion, options) {
131444
131580
  if (assertion.error) {
131445
131581
  lines.push(` ${colors.error(`Error: ${assertion.error}`)}`);
131446
131582
  } else if (!assertion.passed) {
131447
- lines.push(` ${colors.dim("Expected:")} ${formatValue(assertion.rightExpression ?? assertion.operator)}`);
131583
+ const expectedValue = assertion.rightValue !== void 0 ? assertion.rightValue : assertion.rightExpression ?? assertion.operator;
131584
+ lines.push(` ${colors.dim("Expected:")} ${formatValue(expectedValue)}`);
131448
131585
  lines.push(` ${colors.dim("Actual:")} ${formatValue(assertion.leftValue)}`);
131449
131586
  if (verbose && assertion.leftExpression !== String(assertion.leftValue)) {
131450
131587
  lines.push(` ${colors.dim("Expression:")} ${assertion.leftExpression}`);
@@ -131738,12 +131875,13 @@ function generateTestCase(sequenceName, assertion, redaction) {
131738
131875
  let xml = ` <testcase name="${escapeXml(testName)}" classname="${escapeXml(className)}">
131739
131876
  `;
131740
131877
  if (!assertion.passed) {
131741
- const message = assertion.error || `Expected ${assertion.rightExpression || assertion.rightValue}, got ${JSON.stringify(assertion.leftValue)}`;
131878
+ const expectedValue = assertion.rightValue !== void 0 ? assertion.rightValue : assertion.rightExpression;
131879
+ const message = assertion.error || `Expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(assertion.leftValue)}`;
131742
131880
  xml += ` <failure message="${escapeXml(redactString(message, redaction))}" type="AssertionError">
131743
131881
  `;
131744
131882
  xml += `<![CDATA[Expression: ${assertion.expression}
131745
131883
  `;
131746
- xml += `Expected: ${JSON.stringify(assertion.rightValue)}
131884
+ xml += `Expected: ${JSON.stringify(expectedValue)}
131747
131885
  `;
131748
131886
  xml += `Actual: ${JSON.stringify(assertion.leftValue)}]]>
131749
131887
  `;
@@ -132115,11 +132253,13 @@ function generateAssertionHtml(step, redaction) {
132115
132253
  } else {
132116
132254
  actualDisplay = String(assertion.leftValue);
132117
132255
  }
132256
+ const expectedValue = assertion.rightValue !== void 0 ? assertion.rightValue : assertion.rightExpression;
132257
+ const expectedDisplay = typeof expectedValue === "object" ? JSON.stringify(expectedValue, null, 2) : String(expectedValue);
132118
132258
  const pathInfo = assertion.jsonPath ? `<div class="assertion-path"><strong>Path:</strong> <code>${escapeHtml(assertion.jsonPath)}</code></div>` : "";
132119
132259
  detailsHtml = `
132120
132260
  <div class="assertion-details">
132121
132261
  ${pathInfo}
132122
- <div><strong>Expected:</strong> <code>${escapeHtml(String(assertion.rightExpression || assertion.rightValue))}</code></div>
132262
+ <div><strong>Expected:</strong> <code>${escapeHtml(expectedDisplay)}</code></div>
132123
132263
  <div><strong>Actual:</strong> <code>${escapeHtml(actualDisplay)}</code></div>
132124
132264
  ${assertion.error ? `<div class="error"><strong>Error:</strong> ${escapeHtml(redactString(assertion.error, redaction))}</div>` : ""}
132125
132265
  </div>`;
@@ -133770,6 +133910,294 @@ function splitImportResolutionErrors(errors) {
133770
133910
  return { blockingErrors, warningErrors };
133771
133911
  }
133772
133912
 
133913
+ // src/nornenvLanguageModel.ts
133914
+ var SECTION_HEADER_REGEX = /^\s*\[(env|template):([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s+extends\s+([^\]]+))?\]\s*$/i;
133915
+ function parseParentList(clause) {
133916
+ if (!clause) {
133917
+ return [];
133918
+ }
133919
+ return clause.split(",").map((parent) => parent.trim()).filter((parent) => /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(parent));
133920
+ }
133921
+ function parseNornenvDocumentModel(text) {
133922
+ const sections = [];
133923
+ const declarations = [];
133924
+ const lines = text.split("\n");
133925
+ let currentSection;
133926
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
133927
+ const line2 = lines[lineNumber];
133928
+ const sectionMatch = line2.trim().match(SECTION_HEADER_REGEX);
133929
+ if (sectionMatch) {
133930
+ currentSection = {
133931
+ kind: sectionMatch[1].toLowerCase(),
133932
+ name: sectionMatch[2],
133933
+ parents: parseParentList(sectionMatch[3]),
133934
+ lineNumber
133935
+ };
133936
+ sections.push(currentSection);
133937
+ continue;
133938
+ }
133939
+ const connectionMatch = line2.match(/^(\s*)(secret\s+)?connectionString\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/i);
133940
+ if (connectionMatch) {
133941
+ const displayName = connectionMatch[3];
133942
+ const name = `${displayName}_connectionString`;
133943
+ const nameStart = line2.indexOf(displayName);
133944
+ const value = connectionMatch[4];
133945
+ const valueStart = line2.length - value.length;
133946
+ declarations.push({
133947
+ name,
133948
+ displayName,
133949
+ value,
133950
+ secret: Boolean(connectionMatch[2]),
133951
+ sectionKind: currentSection?.kind,
133952
+ sectionName: currentSection?.name,
133953
+ lineNumber,
133954
+ nameStart,
133955
+ nameEnd: nameStart + displayName.length,
133956
+ valueStart,
133957
+ valueEnd: line2.length
133958
+ });
133959
+ continue;
133960
+ }
133961
+ const variableMatch = line2.match(/^(\s*)(secret|var)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/i);
133962
+ if (variableMatch) {
133963
+ const name = variableMatch[3];
133964
+ const nameStart = line2.indexOf(name);
133965
+ const value = variableMatch[4];
133966
+ const valueStart = line2.length - value.length;
133967
+ declarations.push({
133968
+ name,
133969
+ displayName: name,
133970
+ value,
133971
+ secret: variableMatch[2].toLowerCase() === "secret",
133972
+ sectionKind: currentSection?.kind,
133973
+ sectionName: currentSection?.name,
133974
+ lineNumber,
133975
+ nameStart,
133976
+ nameEnd: nameStart + name.length,
133977
+ valueStart,
133978
+ valueEnd: line2.length
133979
+ });
133980
+ }
133981
+ }
133982
+ return { sections, declarations };
133983
+ }
133984
+
133985
+ // src/nornenvRegionRefactor.ts
133986
+ var MATRIX_ENV_NAME_REGEX = /^([a-zA-Z][a-zA-Z0-9-]*)_([a-zA-Z][a-zA-Z0-9-]*)$/;
133987
+ var MIN_STAGES = 2;
133988
+ var MIN_REGIONS = 2;
133989
+ var MIN_CELLS = 3;
133990
+ function isConnectionStringVar(name) {
133991
+ return name.endsWith("_connectionString");
133992
+ }
133993
+ function inferDeclarationKind(name) {
133994
+ return isConnectionStringVar(name) ? "connectionString" : "var";
133995
+ }
133996
+ function inferDisplayName(name) {
133997
+ return isConnectionStringVar(name) ? name.slice(0, -"_connectionString".length) : name;
133998
+ }
133999
+ function declarationKind(declaration) {
134000
+ return declaration.name.endsWith("_connectionString") && declaration.displayName !== declaration.name ? "connectionString" : "var";
134001
+ }
134002
+ function buildDeclarationMap(text) {
134003
+ const model = parseNornenvDocumentModel(text);
134004
+ const byEnv = /* @__PURE__ */ new Map();
134005
+ for (const declaration of model.declarations) {
134006
+ if (declaration.sectionKind !== "env" || !declaration.sectionName) {
134007
+ continue;
134008
+ }
134009
+ if (!byEnv.has(declaration.sectionName)) {
134010
+ byEnv.set(declaration.sectionName, /* @__PURE__ */ new Map());
134011
+ }
134012
+ byEnv.get(declaration.sectionName).set(declaration.name, declaration);
134013
+ }
134014
+ return byEnv;
134015
+ }
134016
+ function getCellValue(cell, name, declarationsByEnv, config2) {
134017
+ const value = cell.env.variables[name];
134018
+ if (value === void 0) {
134019
+ return void 0;
134020
+ }
134021
+ const declaration = declarationsByEnv.get(cell.envName)?.get(name);
134022
+ return {
134023
+ value,
134024
+ secret: declaration?.secret ?? config2.secretNames.has(name),
134025
+ kind: declaration ? declarationKind(declaration) : inferDeclarationKind(name),
134026
+ displayName: declaration?.displayName ?? inferDisplayName(name)
134027
+ };
134028
+ }
134029
+ function classifyByAxis(name, cells, axisKeys, getAxisKey, declarationsByEnv, config2) {
134030
+ const byKey = /* @__PURE__ */ new Map();
134031
+ let presentCells = 0;
134032
+ for (const key of axisKeys) {
134033
+ const axisCells = cells.filter((cell) => getAxisKey(cell) === key);
134034
+ const presentValues = axisCells.map((cell) => getCellValue(cell, name, declarationsByEnv, config2)).filter((value) => value !== void 0);
134035
+ if (presentValues.length === 0) {
134036
+ continue;
134037
+ }
134038
+ presentCells += presentValues.length;
134039
+ if (presentValues.length !== axisCells.length) {
134040
+ return void 0;
134041
+ }
134042
+ const first = presentValues[0];
134043
+ if (presentValues.some((value) => value.value !== first.value)) {
134044
+ return void 0;
134045
+ }
134046
+ byKey.set(key, {
134047
+ value: first.value,
134048
+ secret: presentValues.some((value) => value.secret),
134049
+ kind: first.kind,
134050
+ displayName: first.displayName
134051
+ });
134052
+ }
134053
+ return byKey.size > 0 && presentCells >= 2 ? byKey : void 0;
134054
+ }
134055
+ function detectRegionPattern(config2, text) {
134056
+ const cells = [];
134057
+ for (const env3 of config2.environments) {
134058
+ const match = env3.name.match(MATRIX_ENV_NAME_REGEX);
134059
+ if (!match) {
134060
+ continue;
134061
+ }
134062
+ if (env3.parents.length > 0) {
134063
+ return void 0;
134064
+ }
134065
+ cells.push({ stage: match[1], region: match[2], envName: env3.name, env: env3 });
134066
+ }
134067
+ const stages = Array.from(new Set(cells.map((c) => c.stage)));
134068
+ const regions = Array.from(new Set(cells.map((c) => c.region)));
134069
+ if (stages.length < MIN_STAGES || regions.length < MIN_REGIONS || cells.length < MIN_CELLS) {
134070
+ return void 0;
134071
+ }
134072
+ if ((/* @__PURE__ */ new Set([...stages, ...regions])).size !== stages.length + regions.length) {
134073
+ return void 0;
134074
+ }
134075
+ const existingTemplates = new Set(config2.templates.map((template) => template.name));
134076
+ if ([...stages, ...regions].some((name) => existingTemplates.has(name))) {
134077
+ return void 0;
134078
+ }
134079
+ const allVarNames = /* @__PURE__ */ new Set();
134080
+ for (const cell of cells) {
134081
+ for (const name of Object.keys(cell.env.variables)) {
134082
+ allVarNames.add(name);
134083
+ }
134084
+ }
134085
+ let liftedToStage = 0;
134086
+ let liftedToRegion = 0;
134087
+ let leafSpecific = 0;
134088
+ let skippedConnectionStrings = 0;
134089
+ const assignments = [];
134090
+ const declarationsByEnv = buildDeclarationMap(text);
134091
+ for (const name of allVarNames) {
134092
+ if (isConnectionStringVar(name)) {
134093
+ skippedConnectionStrings++;
134094
+ }
134095
+ const stageValues = isConnectionStringVar(name) ? void 0 : classifyByAxis(name, cells, stages, (cell) => cell.stage, declarationsByEnv, config2);
134096
+ const regionValues = isConnectionStringVar(name) ? void 0 : classifyByAxis(name, cells, regions, (cell) => cell.region, declarationsByEnv, config2);
134097
+ if (stageValues) {
134098
+ assignments.push({ name, axis: "stage", byKey: stageValues });
134099
+ liftedToStage++;
134100
+ } else if (regionValues) {
134101
+ assignments.push({ name, axis: "region", byKey: regionValues });
134102
+ liftedToRegion++;
134103
+ } else {
134104
+ const byKey = /* @__PURE__ */ new Map();
134105
+ for (const cell of cells) {
134106
+ const value = getCellValue(cell, name, declarationsByEnv, config2);
134107
+ if (value !== void 0) {
134108
+ byKey.set(cell.envName, value);
134109
+ }
134110
+ }
134111
+ if (byKey.size > 0) {
134112
+ assignments.push({ name, axis: "leaf", byKey });
134113
+ leafSpecific++;
134114
+ }
134115
+ }
134116
+ }
134117
+ const model = parseNornenvDocumentModel(text);
134118
+ const matrixNames = new Set(cells.map((c) => c.envName));
134119
+ const matrixSectionLines = model.sections.filter((s) => s.kind === "env" && matrixNames.has(s.name)).map((s) => s.lineNumber).sort((a, b) => a - b);
134120
+ if (matrixSectionLines.length === 0) {
134121
+ return void 0;
134122
+ }
134123
+ const firstLine = matrixSectionLines[0];
134124
+ const lastMatrixHeaderLine = matrixSectionLines[matrixSectionLines.length - 1];
134125
+ const sectionsInReplacementRange = model.sections.filter(
134126
+ (section) => section.lineNumber >= firstLine && section.lineNumber <= lastMatrixHeaderLine
134127
+ );
134128
+ if (sectionsInReplacementRange.some((section) => section.kind !== "env" || !matrixNames.has(section.name))) {
134129
+ return void 0;
134130
+ }
134131
+ const lineCount = text.split("\n").length;
134132
+ const sectionsAfter = model.sections.filter((s) => s.lineNumber > lastMatrixHeaderLine).map((s) => s.lineNumber).sort((a, b) => a - b);
134133
+ const lastLine = sectionsAfter.length > 0 ? sectionsAfter[0] - 1 : lineCount - 1;
134134
+ return {
134135
+ stages,
134136
+ regions,
134137
+ cells,
134138
+ assignments,
134139
+ replaceRange: { startLine: firstLine, endLine: lastLine },
134140
+ summary: { liftedToStage, liftedToRegion, leafSpecific, skippedConnectionStrings }
134141
+ };
134142
+ }
134143
+ function generateRegionRefactor(pattern) {
134144
+ const blocks = [];
134145
+ const fmtVar = (assignment, value) => {
134146
+ if (value.kind === "connectionString") {
134147
+ const secretPrefix = value.secret ? "secret " : "";
134148
+ return ` ${secretPrefix}connectionString ${value.displayName} = ${value.value}`;
134149
+ }
134150
+ const keyword = value.secret ? "secret" : "var";
134151
+ return ` ${keyword} ${assignment.name} = ${value.value}`;
134152
+ };
134153
+ for (const stage of pattern.stages) {
134154
+ const stageVars = pattern.assignments.filter((a) => a.axis === "stage" && a.byKey.has(stage));
134155
+ if (stageVars.length === 0) {
134156
+ continue;
134157
+ }
134158
+ const lines = [`[template:${stage}]`];
134159
+ for (const v of stageVars) {
134160
+ lines.push(fmtVar(v, v.byKey.get(stage)));
134161
+ }
134162
+ blocks.push(lines.join("\n"));
134163
+ }
134164
+ for (const region of pattern.regions) {
134165
+ const regionVars = pattern.assignments.filter((a) => a.axis === "region" && a.byKey.has(region));
134166
+ if (regionVars.length === 0) {
134167
+ continue;
134168
+ }
134169
+ const lines = [`[template:${region}]`];
134170
+ for (const v of regionVars) {
134171
+ lines.push(fmtVar(v, v.byKey.get(region)));
134172
+ }
134173
+ blocks.push(lines.join("\n"));
134174
+ }
134175
+ for (const cell of pattern.cells) {
134176
+ const leafVars = pattern.assignments.filter((a) => a.axis === "leaf" && a.byKey.has(cell.envName));
134177
+ const header = `[env:${cell.envName} extends ${cell.stage}, ${cell.region}]`;
134178
+ if (leafVars.length === 0) {
134179
+ blocks.push(header);
134180
+ continue;
134181
+ }
134182
+ const lines = [header];
134183
+ for (const v of leafVars) {
134184
+ lines.push(fmtVar(v, v.byKey.get(cell.envName)));
134185
+ }
134186
+ blocks.push(lines.join("\n"));
134187
+ }
134188
+ return blocks.join("\n\n");
134189
+ }
134190
+ function applyRegionRefactorToText(text, pattern) {
134191
+ const lines = text.split("\n");
134192
+ const startLine = pattern.replaceRange.startLine;
134193
+ const endLine = Math.min(pattern.replaceRange.endLine, lines.length - 1);
134194
+ const replacementLines = generateRegionRefactor(pattern).split("\n");
134195
+ lines.splice(startLine, endLine - startLine + 1, ...replacementLines);
134196
+ const result = lines.join("\n");
134197
+ return text.endsWith("\n") && !result.endsWith("\n") ? `${result}
134198
+ ` : result;
134199
+ }
134200
+
133773
134201
  // src/cli.ts
133774
134202
  function handleImportResolutionErrors(errors, colors) {
133775
134203
  const { blockingErrors, warningErrors } = splitImportResolutionErrors(errors);
@@ -133884,7 +134312,9 @@ function parseArgs(args) {
133884
134312
  noRedact: false,
133885
134313
  tagFilters: [],
133886
134314
  tagsFilter: [],
133887
- insecure: false
134315
+ insecure: false,
134316
+ refactorRegionPattern: false,
134317
+ writeRefactor: false
133888
134318
  };
133889
134319
  for (let i = 0; i < args.length; i++) {
133890
134320
  const arg = args[i];
@@ -133902,6 +134332,10 @@ function parseArgs(args) {
133902
134332
  options.timeout = parseInt(args[++i], 10) * 1e3;
133903
134333
  } else if (arg === "--insecure") {
133904
134334
  options.insecure = true;
134335
+ } else if (arg === "--refactor-region-pattern" || arg === "--refactor-nornenv-region-pattern") {
134336
+ options.refactorRegionPattern = true;
134337
+ } else if (arg === "--write") {
134338
+ options.writeRefactor = true;
133905
134339
  } else if (arg === "--no-fail") {
133906
134340
  options.failOnError = false;
133907
134341
  } else if (arg === "--no-redact") {
@@ -133957,6 +134391,9 @@ Options:
133957
134391
  -o, --output-dir <dir> Output directory for reports (auto-generates timestamped files)
133958
134392
  --tag <filter> Filter sequences by tag (AND logic, can be repeated)
133959
134393
  --tags <filters> Filter sequences by tags (OR logic, comma-separated)
134394
+ --refactor-region-pattern
134395
+ Refactor a flat .nornenv STAGE_REGION matrix to templates
134396
+ --write Apply --refactor-region-pattern instead of printing result
133960
134397
  -h, --help Show this help message
133961
134398
 
133962
134399
  Report Generation:
@@ -134000,11 +134437,62 @@ Examples:
134000
134437
  norn api-tests.norn --html report.html # Generate HTML report (explicit)
134001
134438
  norn api-tests.norn --insecure # Allow self-signed/local TLS certs
134002
134439
  norn api-tests.norn --no-redact # Show all data (no redaction)
134440
+ norn .nornenv --refactor-region-pattern # Print refactored .nornenv
134441
+ norn .nornenv --refactor-region-pattern --write
134003
134442
  norn secrets keygen --name team-main # Generate shared key and cache locally
134004
134443
  norn secrets import-key --kid team-main # Save shared key from your vault
134005
134444
  norn secrets audit . # Fail if plaintext secrets are committed
134006
134445
  `);
134007
134446
  }
134447
+ function formatRegionPatternSummary(pattern) {
134448
+ const { liftedToStage, liftedToRegion, leafSpecific, skippedConnectionStrings } = pattern.summary;
134449
+ const lines = [
134450
+ `Detected ${pattern.cells.length} envs across ${pattern.stages.length} stages x ${pattern.regions.length} regions.`,
134451
+ `Lifted ${liftedToStage} vars to stage templates (${pattern.stages.join(", ")}).`,
134452
+ `Lifted ${liftedToRegion} vars to region templates (${pattern.regions.join(", ")}).`,
134453
+ `Kept ${leafSpecific} vars leaf-specific.`
134454
+ ];
134455
+ if (skippedConnectionStrings > 0) {
134456
+ lines.push(`Kept ${skippedConnectionStrings} connection-string var${skippedConnectionStrings === 1 ? "" : "s"} in leaf envs.`);
134457
+ }
134458
+ return lines;
134459
+ }
134460
+ function runNornenvRegionRefactor(filePath, options) {
134461
+ const content = fs19.readFileSync(filePath, "utf-8");
134462
+ const config2 = parseEnvFile(content, filePath);
134463
+ const pattern = detectRegionPattern(config2, content);
134464
+ if (!pattern) {
134465
+ if (options.output === "json") {
134466
+ console.log(JSON.stringify({ success: false, changed: false, error: "No region pattern detected" }, null, 2));
134467
+ } else {
134468
+ console.error("No region pattern detected in this .nornenv file.");
134469
+ }
134470
+ process.exit(1);
134471
+ }
134472
+ const refactored = applyRegionRefactorToText(content, pattern);
134473
+ if (options.writeRefactor) {
134474
+ fs19.writeFileSync(filePath, refactored, "utf-8");
134475
+ }
134476
+ if (options.output === "json") {
134477
+ console.log(JSON.stringify({
134478
+ success: true,
134479
+ changed: refactored !== content,
134480
+ file: filePath,
134481
+ summary: pattern.summary,
134482
+ stages: pattern.stages,
134483
+ regions: pattern.regions,
134484
+ output: options.writeRefactor ? void 0 : refactored
134485
+ }, null, 2));
134486
+ } else if (options.writeRefactor) {
134487
+ for (const line2 of formatRegionPatternSummary(pattern)) {
134488
+ console.log(line2);
134489
+ }
134490
+ console.log(`Refactored ${filePath}`);
134491
+ } else {
134492
+ console.log(refactored);
134493
+ }
134494
+ process.exit(0);
134495
+ }
134008
134496
  async function runSingleRequest(fileContent, variables, cookieJar, apiDefinitions, filePath, envContext) {
134009
134497
  const lines = fileContent.split("\n");
134010
134498
  const requestLines = [];
@@ -134169,6 +134657,17 @@ async function main() {
134169
134657
  process.exit(1);
134170
134658
  }
134171
134659
  const isDirectory = fs19.statSync(inputPath).isDirectory();
134660
+ if (options.writeRefactor && !options.refactorRegionPattern) {
134661
+ console.error("Error: --write can only be used with --refactor-region-pattern");
134662
+ process.exit(1);
134663
+ }
134664
+ if (options.refactorRegionPattern) {
134665
+ if (isDirectory) {
134666
+ console.error("Error: --refactor-region-pattern requires a specific .nornenv file, not a directory");
134667
+ process.exit(1);
134668
+ }
134669
+ runNornenvRegionRefactor(inputPath, options);
134670
+ }
134172
134671
  let filesToRun;
134173
134672
  if (isDirectory) {
134174
134673
  filesToRun = discoverNornFiles(inputPath);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "norn-cli",
3
3
  "displayName": "Norn — API Tests in Your Repo",
4
4
  "description": "Version-controlled API tests your team can keep. Author and debug HTTP sequences in VS Code, then run the same files in CI.",
5
- "version": "2.3.0",
5
+ "version": "2.5.0",
6
6
  "publisher": "Norn-PeterKrustanov",
7
7
  "author": {
8
8
  "name": "Peter Krastanov"
@@ -103,6 +103,11 @@
103
103
  "title": "Peek Inherited Norn Environment Variables",
104
104
  "category": "Norn"
105
105
  },
106
+ {
107
+ "command": "norn.nornenv.refactorRegionPattern",
108
+ "title": "Refactor Region Pattern To Templates",
109
+ "category": "Norn"
110
+ },
106
111
  {
107
112
  "command": "norn.showCoverage",
108
113
  "title": "Show API Coverage",
@@ -410,6 +415,14 @@
410
415
  "type": "boolean",
411
416
  "default": true,
412
417
  "description": "Verify SSL/TLS certificates for HTTPS requests and Swagger/OpenAPI fetches. Disable only for local development with self-signed certificates."
418
+ },
419
+ "norn.testExplorer.exclude": {
420
+ "type": "array",
421
+ "default": [],
422
+ "items": {
423
+ "type": "string"
424
+ },
425
+ "description": "Workspace-relative glob patterns for .norn files or folders to hide from the Norn Test Explorer. Useful for negative fixtures, live-only tests, or documentation demos."
413
426
  }
414
427
  }
415
428
  },
@@ -440,7 +453,7 @@
440
453
  "watch:esbuild": "node esbuild.js --watch",
441
454
  "watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
442
455
  "package": "npm run check-types && npm run lint && node esbuild.js --production",
443
- "compile-tests": "tsc -p . --outDir out",
456
+ "compile-tests": "node -e \"require('fs').rmSync('out/test',{recursive:true,force:true})\" && tsc -p . --outDir out",
444
457
  "watch-tests": "tsc -p . -w --outDir out",
445
458
  "pretest": "npm run compile-tests && npm run compile && npm run lint",
446
459
  "check-types": "tsc --noEmit",
@@ -448,7 +461,8 @@
448
461
  "validate:skills": "node ./scripts/validate-skills.mjs",
449
462
  "test": "vscode-test",
450
463
  "test:regression": "node ./dist/cli.js ./tests/Regression/ -e prelive",
451
- "publish:npm": "node -e \"const p=require('./package.json');p.name='norn-cli';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))\" && npm publish && node -e \"const p=require('./package.json');p.name='norn';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))\"",
464
+ "test:prerelease": "npm test && npm run test:regression",
465
+ "publish:npm": "node -e \"const fs=require('fs');const p=require('./package.json');p.name='norn-cli';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n')\" && npm publish; exit_code=$?; node -e \"const fs=require('fs');const p=require('./package.json');p.name='norn';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n')\"; exit $exit_code",
452
466
  "publish:vsce": "node -e \"const p=require('./package.json');p.name='norn';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))\" && npx vsce publish",
453
467
  "publish:all": "npm run publish:npm && npm run publish:vsce"
454
468
  },
@@ -480,4 +494,4 @@
480
494
  "bin": {
481
495
  "norn": "./dist/cli.js"
482
496
  }
483
- }
497
+ }
@@ -1,18 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm run *)",
5
- "WebSearch",
6
- "Bash(git checkout *)",
7
- "Bash(node ./dist/cli.js tests/Regression/nornenv-templates/extends-resolution.norn -s PrintResolvedValues -e prod_uk)",
8
- "Bash(node ./dist/cli.js tests/Regression/nornenv-templates/extends-resolution.norn -s PrintResolvedValues -e prod_us)",
9
- "Bash(node ./dist/cli.js tests/Regression/nornenv-templates/extends-resolution.norn -s PrintResolvedValues -e diamond)",
10
- "Bash(node ./dist/cli.js tests/Regression/nornenv-templates/extends-resolution.norn -s PrintResolvedValues -e base)",
11
- "Bash(mv .nornenv .nornenv.bak)",
12
- "Bash(cp cycle.nornenv .nornenv)",
13
- "Bash(timeout 10 node ../../../dist/cli.js extends-resolution.norn -s PrintResolvedValues -e c)",
14
- "Bash(time node ../../../dist/cli.js extends-resolution.norn -s PrintResolvedValues -e c)",
15
- "Bash(time node /Users/petercrest/Worktable/Projects/vsApi/dist/cli.js extends-resolution.norn -s PrintResolvedValues -e c)"
16
- ]
17
- }
18
- }