norn-cli 2.3.0 → 2.4.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.
Files changed (92) hide show
  1. package/.claude/skills/norn-social-campaign/SKILL.md +70 -0
  2. package/CHANGELOG.md +6 -0
  3. package/demos/nornenv-region-refactor/README.md +64 -0
  4. package/dist/cli.js +360 -1
  5. package/out/apiResponseIntellisenseCache.js +394 -0
  6. package/out/assertionRunner.js +567 -0
  7. package/out/cacheDir.js +136 -0
  8. package/out/chatParticipant.js +763 -0
  9. package/out/cli/colors.js +127 -0
  10. package/out/cli/formatters/assertion.js +102 -0
  11. package/out/cli/formatters/index.js +23 -0
  12. package/out/cli/formatters/response.js +106 -0
  13. package/out/cli/formatters/summary.js +246 -0
  14. package/out/cli/redaction.js +237 -0
  15. package/out/cli/reporters/html.js +689 -0
  16. package/out/cli/reporters/index.js +22 -0
  17. package/out/cli/reporters/junit.js +226 -0
  18. package/out/codeLensProvider.js +351 -0
  19. package/out/compareContentProvider.js +85 -0
  20. package/out/completionProvider.js +3739 -0
  21. package/out/contractAssertionSummary.js +225 -0
  22. package/out/contractDecorationProvider.js +243 -0
  23. package/out/coverageCalculator.js +879 -0
  24. package/out/coveragePanel.js +597 -0
  25. package/out/debug/breakpointResolver.js +84 -0
  26. package/out/debug/breakpoints.js +52 -0
  27. package/out/debug/nornDebugAdapter.js +166 -0
  28. package/out/debug/nornDebugSession.js +613 -0
  29. package/out/debug/sequenceLocationIndex.js +77 -0
  30. package/out/debug/types.js +3 -0
  31. package/out/deepClone.js +21 -0
  32. package/out/diagnosticProvider.js +2554 -0
  33. package/out/environmentParser.js +736 -0
  34. package/out/environmentProvider.js +544 -0
  35. package/out/environmentTemplates.js +146 -0
  36. package/out/errors/formatError.js +113 -0
  37. package/out/errors/nornError.js +29 -0
  38. package/out/formUrlEncoded.js +89 -0
  39. package/out/httpClient.js +348 -0
  40. package/out/httpRuntimeOptions.js +16 -0
  41. package/out/importErrors.js +31 -0
  42. package/out/inlayHintResolver.js +70 -0
  43. package/out/jsonFileReader.js +323 -0
  44. package/out/mcpClient.js +193 -0
  45. package/out/mcpConfig.js +184 -0
  46. package/out/mcpToolIntellisenseCache.js +96 -0
  47. package/out/mcpToolSchema.js +50 -0
  48. package/out/nornConfig.js +132 -0
  49. package/out/nornHoverProvider.js +124 -0
  50. package/out/nornInlayHintsProvider.js +191 -0
  51. package/out/nornPrompt.js +755 -0
  52. package/out/nornSqlParser.js +286 -0
  53. package/out/nornapiHoverProvider.js +135 -0
  54. package/out/nornapiInlayHintsProvider.js +94 -0
  55. package/out/nornapiParser.js +324 -0
  56. package/out/nornenvCodeActionProvider.js +101 -0
  57. package/out/nornenvDecorationProvider.js +239 -0
  58. package/out/nornenvFoldingProvider.js +63 -0
  59. package/out/nornenvHoverProvider.js +114 -0
  60. package/out/nornenvInlayHintsProvider.js +99 -0
  61. package/out/nornenvLanguageModel.js +187 -0
  62. package/out/nornenvRegionRefactor.js +267 -0
  63. package/out/nornsqlHoverProvider.js +95 -0
  64. package/out/nornsqlInlayHintsProvider.js +114 -0
  65. package/out/parser.js +839 -0
  66. package/out/pathAccess.js +28 -0
  67. package/out/postmanImportPanel.js +732 -0
  68. package/out/postmanImportPlanner.js +1155 -0
  69. package/out/postmanImportSidebarView.js +532 -0
  70. package/out/quotedString.js +35 -0
  71. package/out/requestPreparation.js +179 -0
  72. package/out/requestValidation.js +146 -0
  73. package/out/responsePanel.js +7754 -0
  74. package/out/schemaGenerator.js +562 -0
  75. package/out/scriptRunner.js +419 -0
  76. package/out/secrets/cliSecrets.js +415 -0
  77. package/out/secrets/crypto.js +105 -0
  78. package/out/secrets/envFileSecrets.js +177 -0
  79. package/out/secrets/keyStore.js +259 -0
  80. package/out/sequenceDeclaration.js +15 -0
  81. package/out/sequenceRunner.js +3590 -0
  82. package/out/sqlAdapterRunner.js +122 -0
  83. package/out/sqlBuiltInAdapters.js +604 -0
  84. package/out/sqlConfig.js +184 -0
  85. package/out/starterCatalog.js +554 -0
  86. package/out/stringUtils.js +25 -0
  87. package/out/swaggerBodyIntellisenseCache.js +114 -0
  88. package/out/swaggerParser.js +464 -0
  89. package/out/testProvider.js +767 -0
  90. package/out/theoryCaseLoader.js +113 -0
  91. package/out/validationCache.js +211 -0
  92. package/package.json +6 -1
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: norn-social-campaign
3
+ description: Continue Peter's Norn LinkedIn social-media campaign — draft/review/schedule posts, reply to engagement, evaluate Week-N kill-criteria, log community signals. Use whenever the user asks about Norn LinkedIn posts, replies, engagement metrics, the GTM experiment, what to post next, or any social/content work for Norn.
4
+ ---
5
+
6
+ # Norn Social Media Campaign — Cross-Session Continuity
7
+
8
+ You are resuming an ongoing GTM experiment Peter has been running for **Norn** (a commercial VS Code extension + CLI he builds solo while employed full-time, UK-based). Most strategic decisions are **locked** — your job is to execute within them, not re-litigate. The canonical state lives in repo `Docs/`; read those before doing real work.
9
+
10
+ ## Read these first (canonical, in this repo)
11
+
12
+ - `Docs/gtm_plan.md` — **the master plan**. Locked positioning, 13-week kill-criteria with anchored dates, action steps, and an append-only **Progress Log** (scroll to the bottom to see exactly where things stand).
13
+ - `Docs/mot_reaction_series.md` — **the LinkedIn posts** (the filename is misnamed — see Anti-patterns; rename pending).
14
+ - `Docs/linkedin_series.md` — LinkedIn delivery rules (tags, first-comment text, image policy, posting cadence).
15
+ - `Docs/market_signals.md` — append-only community pain-signal intel log. Append; don't re-derive.
16
+ - `Docs/postman_rot_essay.md` (long-form v3) and `Docs/mot_series.md` (v1 micro-posts) — backup bank, not active.
17
+
18
+ Memory files `norn-gtm-decision.md` and `norn-messaging-locked.md` auto-load and carry the durable decisions.
19
+
20
+ ## What is LOCKED — do not reopen
21
+
22
+ - **Positioning spine (verbatim, never paraphrase):**
23
+ > Norn turns API requests into version-controlled tests your whole team can keep — authored and debugged in VS Code, run on every PR in CI.
24
+ Category = repo-native API regression testing. NOT "REST client", NOT vaguer "workflow automation."
25
+ - **GTM motion:** public commercial launch (PLG + upmarket founder-led semi-sales). 13-week time-boxed experiment. Kill-criteria + anchored dates in `gtm_plan.md`. "CV-asset" is an accepted non-failure outcome.
26
+ - **Sell to:** CTO / founding eng / tech lead at small companies (~≤20–30 people). **QA = evangelist, not buyer.** Eng Manager parked until 50+ people.
27
+ - **Pricing model:** VS Code Extension is **free forever (incl. commercial).** CLI is **free for local use.** **Paid only for CLI Pipeline Use (CI/CD).** No evaluation period. Encoded in `LICENSE` (vsApi + Norn, kept in sync).
28
+ - **Channel: LinkedIn only.** MoT dropped 2026-05-18. One uniform series posted to LinkedIn.
29
+ - **Voice:** dry, specific, recognition-humor, no slang/meme/"touch grass" register, clarity > cleverness, no clever coda after the closing question. ~150–220 words per post.
30
+ - **Canonical ending** (append verbatim, URL-free, to every post):
31
+ > Disclosure so nobody feels tricked: I build Norn — my attempt at the in-the-repo fix. That's the entire pitch; the post stands without it. Still genuinely curious about the question above, though.
32
+ - **Tags (fixed, every post):** `#API #Testing #DeveloperTools`. Add `#MCP` only on the MCP post. 4 max.
33
+ - **First comment** (within ~1 min of posting):
34
+ > The Norn bit I mentioned, if you want to poke at it: https://nornapi.com
35
+ - **Posting cadence:** Tue–Thu ~8:30am UK time, one post/day, ≥4–5 days apart.
36
+ - **Visual rule:** real source screenshot (with identifying bits cropped) if a genuine artifact exists; text-only otherwise. **Never** Norn logo. **Never** AI imagery.
37
+ - **Self-presentation:** "QA / automation engineer who builds Norn." **Never "Founder."** The reason is strategic (credibility + disclosure-not-pitch posture), not legal — Peter's employment contract is permissive, employer has long known.
38
+
39
+ ## Anti-patterns — do NOT do
40
+
41
+ - **Don't reopen the positioning spine.** It was rewritten many times before locking; uniformity *is* the strategy.
42
+ - **Don't suggest pitching Peter's employer.** Internal-champion path was closed 2026-05-18 — declined "prefer not to sour the relationship" (structural/relationship objection, not product, not IP). Their "no" is excluded from any market-signal read.
43
+ - **Don't raise the employer IP-alarm.** Contract permissive; employer long aware; Peter refused a restrictive replacement years ago specifically to protect this. Settled.
44
+ - **Don't suggest MoT / Reddit / forums as a posting channel.** Dropped. LinkedIn only.
45
+ - **Don't pitch Norn in post bodies or in comment replies.** The canonical ending + first comment do all the selling. Replies = host of a conversation, not salesperson.
46
+ - **Don't suggest a separate LinkedIn-native content stream.** One uniform series. Per-channel variants caused the consistency problem the whole session was about.
47
+ - **Don't add clever/meta codas after the closing question.** Two prior attempts ("wishful Slack message", "I want to collect these") had to be fixed because they were too clever for the reader. Plain question close, full stop.
48
+ - **Don't suggest building product features speculatively.** Build bets in `market_signals.md` are *candidates* — build only after Week-7+ signals justify. "Don't build on spec."
49
+ - **Don't write more posts on spec before the Week-4 verdict.** If the format doesn't travel, posts 7+ are wasted on a wrong format.
50
+ - **The file `mot_reaction_series.md` is misnamed** (no MoT anymore). Rename to `linkedin_posts.md` is a pending cleanup; cross-refs in `gtm_plan.md` and `linkedin_series.md` will need updating in the same pass.
51
+
52
+ ## How to handle common requests
53
+
54
+ - **"Give me the next post" / "what should I post":** check the Progress Log in `gtm_plan.md` to see which posts are scheduled vs sent. Posts live in `mot_reaction_series.md`. Format LinkedIn-ready: strip the internal `## N. Title` label; strip markdown asterisks (LinkedIn shows them literally); append the canonical ending; append the tag line; provide the first-comment text separately. Image: real cropped screenshot if a genuine source exists, otherwise text-only.
55
+ - **"Reply to this comment":** stay in voice; *extend* the discussion, never thank generically; no Norn pitch in the reply (the bio carries it); reply **fast** — early engagement is the single biggest reach lever, far above any other tactic.
56
+ - **"How's the post doing?" / metrics:** calibrate via ratios more than raw numbers. Comment-to-reaction ratio above ~8% = high engagement. A **Send** (LinkedIn DM forward) is the highest-value action — it's the peer-to-peer endorsement the plan was built for. Saves > likes in intent. n=1 is not a trend.
57
+ - **"Week-N review":** open the kill-criteria table in `gtm_plan.md` (dates anchored to 2026-05-21 launch). Evaluate signals honestly against the rule. **The pre-committed exit only works if the review actually happens** — sunk cost will narrate; resist it.
58
+ - **New community pain signal (Reddit/Lobsters/HN):** append verbatim quote + URL + date to `Docs/market_signals.md`, triage into LinkedIn angle + build-bet columns. Don't let mining displace posting.
59
+
60
+ ## Working style with Peter
61
+
62
+ - Convert relative dates to absolute when logging (UK timezone).
63
+ - Peter is a non-writer; he trusts drafts. When asked for content, give **clean, copy-pasteable** text. No padding, no "here you go" framing.
64
+ - Peter values **brevity, decisiveness, and honest pushback** over compliance. If something he's about to do is wrong, say so directly with reasoning. Don't be a yes-man — he's caught me on it before.
65
+ - **Consistency is his #1 value.** Keep the Docs as the single source of truth. Any time copy gets revised, sync everywhere it appears.
66
+ - When in doubt about state, **read the bottom of `gtm_plan.md`'s Progress Log** — that's where the truth lives.
67
+
68
+ ---
69
+
70
+ This skill is the index. The `Docs/` files are the canon. When in doubt, read them.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to the "Norn" extension will be documented in this file.
4
4
 
5
+ ## [2.4.0] - 2026-05-25
6
+
7
+ ### Added
8
+ - **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.
9
+ - **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.
10
+
5
11
  ## [2.3.0] - 2026-05-20
6
12
 
7
13
  ### Added
@@ -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
@@ -133770,6 +133770,294 @@ function splitImportResolutionErrors(errors) {
133770
133770
  return { blockingErrors, warningErrors };
133771
133771
  }
133772
133772
 
133773
+ // src/nornenvLanguageModel.ts
133774
+ var SECTION_HEADER_REGEX = /^\s*\[(env|template):([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s+extends\s+([^\]]+))?\]\s*$/i;
133775
+ function parseParentList(clause) {
133776
+ if (!clause) {
133777
+ return [];
133778
+ }
133779
+ return clause.split(",").map((parent) => parent.trim()).filter((parent) => /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(parent));
133780
+ }
133781
+ function parseNornenvDocumentModel(text) {
133782
+ const sections = [];
133783
+ const declarations = [];
133784
+ const lines = text.split("\n");
133785
+ let currentSection;
133786
+ for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
133787
+ const line2 = lines[lineNumber];
133788
+ const sectionMatch = line2.trim().match(SECTION_HEADER_REGEX);
133789
+ if (sectionMatch) {
133790
+ currentSection = {
133791
+ kind: sectionMatch[1].toLowerCase(),
133792
+ name: sectionMatch[2],
133793
+ parents: parseParentList(sectionMatch[3]),
133794
+ lineNumber
133795
+ };
133796
+ sections.push(currentSection);
133797
+ continue;
133798
+ }
133799
+ const connectionMatch = line2.match(/^(\s*)(secret\s+)?connectionString\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/i);
133800
+ if (connectionMatch) {
133801
+ const displayName = connectionMatch[3];
133802
+ const name = `${displayName}_connectionString`;
133803
+ const nameStart = line2.indexOf(displayName);
133804
+ const value = connectionMatch[4];
133805
+ const valueStart = line2.length - value.length;
133806
+ declarations.push({
133807
+ name,
133808
+ displayName,
133809
+ value,
133810
+ secret: Boolean(connectionMatch[2]),
133811
+ sectionKind: currentSection?.kind,
133812
+ sectionName: currentSection?.name,
133813
+ lineNumber,
133814
+ nameStart,
133815
+ nameEnd: nameStart + displayName.length,
133816
+ valueStart,
133817
+ valueEnd: line2.length
133818
+ });
133819
+ continue;
133820
+ }
133821
+ const variableMatch = line2.match(/^(\s*)(secret|var)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/i);
133822
+ if (variableMatch) {
133823
+ const name = variableMatch[3];
133824
+ const nameStart = line2.indexOf(name);
133825
+ const value = variableMatch[4];
133826
+ const valueStart = line2.length - value.length;
133827
+ declarations.push({
133828
+ name,
133829
+ displayName: name,
133830
+ value,
133831
+ secret: variableMatch[2].toLowerCase() === "secret",
133832
+ sectionKind: currentSection?.kind,
133833
+ sectionName: currentSection?.name,
133834
+ lineNumber,
133835
+ nameStart,
133836
+ nameEnd: nameStart + name.length,
133837
+ valueStart,
133838
+ valueEnd: line2.length
133839
+ });
133840
+ }
133841
+ }
133842
+ return { sections, declarations };
133843
+ }
133844
+
133845
+ // src/nornenvRegionRefactor.ts
133846
+ var MATRIX_ENV_NAME_REGEX = /^([a-zA-Z][a-zA-Z0-9-]*)_([a-zA-Z][a-zA-Z0-9-]*)$/;
133847
+ var MIN_STAGES = 2;
133848
+ var MIN_REGIONS = 2;
133849
+ var MIN_CELLS = 3;
133850
+ function isConnectionStringVar(name) {
133851
+ return name.endsWith("_connectionString");
133852
+ }
133853
+ function inferDeclarationKind(name) {
133854
+ return isConnectionStringVar(name) ? "connectionString" : "var";
133855
+ }
133856
+ function inferDisplayName(name) {
133857
+ return isConnectionStringVar(name) ? name.slice(0, -"_connectionString".length) : name;
133858
+ }
133859
+ function declarationKind(declaration) {
133860
+ return declaration.name.endsWith("_connectionString") && declaration.displayName !== declaration.name ? "connectionString" : "var";
133861
+ }
133862
+ function buildDeclarationMap(text) {
133863
+ const model = parseNornenvDocumentModel(text);
133864
+ const byEnv = /* @__PURE__ */ new Map();
133865
+ for (const declaration of model.declarations) {
133866
+ if (declaration.sectionKind !== "env" || !declaration.sectionName) {
133867
+ continue;
133868
+ }
133869
+ if (!byEnv.has(declaration.sectionName)) {
133870
+ byEnv.set(declaration.sectionName, /* @__PURE__ */ new Map());
133871
+ }
133872
+ byEnv.get(declaration.sectionName).set(declaration.name, declaration);
133873
+ }
133874
+ return byEnv;
133875
+ }
133876
+ function getCellValue(cell, name, declarationsByEnv, config2) {
133877
+ const value = cell.env.variables[name];
133878
+ if (value === void 0) {
133879
+ return void 0;
133880
+ }
133881
+ const declaration = declarationsByEnv.get(cell.envName)?.get(name);
133882
+ return {
133883
+ value,
133884
+ secret: declaration?.secret ?? config2.secretNames.has(name),
133885
+ kind: declaration ? declarationKind(declaration) : inferDeclarationKind(name),
133886
+ displayName: declaration?.displayName ?? inferDisplayName(name)
133887
+ };
133888
+ }
133889
+ function classifyByAxis(name, cells, axisKeys, getAxisKey, declarationsByEnv, config2) {
133890
+ const byKey = /* @__PURE__ */ new Map();
133891
+ let presentCells = 0;
133892
+ for (const key of axisKeys) {
133893
+ const axisCells = cells.filter((cell) => getAxisKey(cell) === key);
133894
+ const presentValues = axisCells.map((cell) => getCellValue(cell, name, declarationsByEnv, config2)).filter((value) => value !== void 0);
133895
+ if (presentValues.length === 0) {
133896
+ continue;
133897
+ }
133898
+ presentCells += presentValues.length;
133899
+ if (presentValues.length !== axisCells.length) {
133900
+ return void 0;
133901
+ }
133902
+ const first = presentValues[0];
133903
+ if (presentValues.some((value) => value.value !== first.value)) {
133904
+ return void 0;
133905
+ }
133906
+ byKey.set(key, {
133907
+ value: first.value,
133908
+ secret: presentValues.some((value) => value.secret),
133909
+ kind: first.kind,
133910
+ displayName: first.displayName
133911
+ });
133912
+ }
133913
+ return byKey.size > 0 && presentCells >= 2 ? byKey : void 0;
133914
+ }
133915
+ function detectRegionPattern(config2, text) {
133916
+ const cells = [];
133917
+ for (const env3 of config2.environments) {
133918
+ const match = env3.name.match(MATRIX_ENV_NAME_REGEX);
133919
+ if (!match) {
133920
+ continue;
133921
+ }
133922
+ if (env3.parents.length > 0) {
133923
+ return void 0;
133924
+ }
133925
+ cells.push({ stage: match[1], region: match[2], envName: env3.name, env: env3 });
133926
+ }
133927
+ const stages = Array.from(new Set(cells.map((c) => c.stage)));
133928
+ const regions = Array.from(new Set(cells.map((c) => c.region)));
133929
+ if (stages.length < MIN_STAGES || regions.length < MIN_REGIONS || cells.length < MIN_CELLS) {
133930
+ return void 0;
133931
+ }
133932
+ if ((/* @__PURE__ */ new Set([...stages, ...regions])).size !== stages.length + regions.length) {
133933
+ return void 0;
133934
+ }
133935
+ const existingTemplates = new Set(config2.templates.map((template) => template.name));
133936
+ if ([...stages, ...regions].some((name) => existingTemplates.has(name))) {
133937
+ return void 0;
133938
+ }
133939
+ const allVarNames = /* @__PURE__ */ new Set();
133940
+ for (const cell of cells) {
133941
+ for (const name of Object.keys(cell.env.variables)) {
133942
+ allVarNames.add(name);
133943
+ }
133944
+ }
133945
+ let liftedToStage = 0;
133946
+ let liftedToRegion = 0;
133947
+ let leafSpecific = 0;
133948
+ let skippedConnectionStrings = 0;
133949
+ const assignments = [];
133950
+ const declarationsByEnv = buildDeclarationMap(text);
133951
+ for (const name of allVarNames) {
133952
+ if (isConnectionStringVar(name)) {
133953
+ skippedConnectionStrings++;
133954
+ }
133955
+ const stageValues = isConnectionStringVar(name) ? void 0 : classifyByAxis(name, cells, stages, (cell) => cell.stage, declarationsByEnv, config2);
133956
+ const regionValues = isConnectionStringVar(name) ? void 0 : classifyByAxis(name, cells, regions, (cell) => cell.region, declarationsByEnv, config2);
133957
+ if (stageValues) {
133958
+ assignments.push({ name, axis: "stage", byKey: stageValues });
133959
+ liftedToStage++;
133960
+ } else if (regionValues) {
133961
+ assignments.push({ name, axis: "region", byKey: regionValues });
133962
+ liftedToRegion++;
133963
+ } else {
133964
+ const byKey = /* @__PURE__ */ new Map();
133965
+ for (const cell of cells) {
133966
+ const value = getCellValue(cell, name, declarationsByEnv, config2);
133967
+ if (value !== void 0) {
133968
+ byKey.set(cell.envName, value);
133969
+ }
133970
+ }
133971
+ if (byKey.size > 0) {
133972
+ assignments.push({ name, axis: "leaf", byKey });
133973
+ leafSpecific++;
133974
+ }
133975
+ }
133976
+ }
133977
+ const model = parseNornenvDocumentModel(text);
133978
+ const matrixNames = new Set(cells.map((c) => c.envName));
133979
+ const matrixSectionLines = model.sections.filter((s) => s.kind === "env" && matrixNames.has(s.name)).map((s) => s.lineNumber).sort((a, b) => a - b);
133980
+ if (matrixSectionLines.length === 0) {
133981
+ return void 0;
133982
+ }
133983
+ const firstLine = matrixSectionLines[0];
133984
+ const lastMatrixHeaderLine = matrixSectionLines[matrixSectionLines.length - 1];
133985
+ const sectionsInReplacementRange = model.sections.filter(
133986
+ (section) => section.lineNumber >= firstLine && section.lineNumber <= lastMatrixHeaderLine
133987
+ );
133988
+ if (sectionsInReplacementRange.some((section) => section.kind !== "env" || !matrixNames.has(section.name))) {
133989
+ return void 0;
133990
+ }
133991
+ const lineCount = text.split("\n").length;
133992
+ const sectionsAfter = model.sections.filter((s) => s.lineNumber > lastMatrixHeaderLine).map((s) => s.lineNumber).sort((a, b) => a - b);
133993
+ const lastLine = sectionsAfter.length > 0 ? sectionsAfter[0] - 1 : lineCount - 1;
133994
+ return {
133995
+ stages,
133996
+ regions,
133997
+ cells,
133998
+ assignments,
133999
+ replaceRange: { startLine: firstLine, endLine: lastLine },
134000
+ summary: { liftedToStage, liftedToRegion, leafSpecific, skippedConnectionStrings }
134001
+ };
134002
+ }
134003
+ function generateRegionRefactor(pattern) {
134004
+ const blocks = [];
134005
+ const fmtVar = (assignment, value) => {
134006
+ if (value.kind === "connectionString") {
134007
+ const secretPrefix = value.secret ? "secret " : "";
134008
+ return ` ${secretPrefix}connectionString ${value.displayName} = ${value.value}`;
134009
+ }
134010
+ const keyword = value.secret ? "secret" : "var";
134011
+ return ` ${keyword} ${assignment.name} = ${value.value}`;
134012
+ };
134013
+ for (const stage of pattern.stages) {
134014
+ const stageVars = pattern.assignments.filter((a) => a.axis === "stage" && a.byKey.has(stage));
134015
+ if (stageVars.length === 0) {
134016
+ continue;
134017
+ }
134018
+ const lines = [`[template:${stage}]`];
134019
+ for (const v of stageVars) {
134020
+ lines.push(fmtVar(v, v.byKey.get(stage)));
134021
+ }
134022
+ blocks.push(lines.join("\n"));
134023
+ }
134024
+ for (const region of pattern.regions) {
134025
+ const regionVars = pattern.assignments.filter((a) => a.axis === "region" && a.byKey.has(region));
134026
+ if (regionVars.length === 0) {
134027
+ continue;
134028
+ }
134029
+ const lines = [`[template:${region}]`];
134030
+ for (const v of regionVars) {
134031
+ lines.push(fmtVar(v, v.byKey.get(region)));
134032
+ }
134033
+ blocks.push(lines.join("\n"));
134034
+ }
134035
+ for (const cell of pattern.cells) {
134036
+ const leafVars = pattern.assignments.filter((a) => a.axis === "leaf" && a.byKey.has(cell.envName));
134037
+ const header = `[env:${cell.envName} extends ${cell.stage}, ${cell.region}]`;
134038
+ if (leafVars.length === 0) {
134039
+ blocks.push(header);
134040
+ continue;
134041
+ }
134042
+ const lines = [header];
134043
+ for (const v of leafVars) {
134044
+ lines.push(fmtVar(v, v.byKey.get(cell.envName)));
134045
+ }
134046
+ blocks.push(lines.join("\n"));
134047
+ }
134048
+ return blocks.join("\n\n");
134049
+ }
134050
+ function applyRegionRefactorToText(text, pattern) {
134051
+ const lines = text.split("\n");
134052
+ const startLine = pattern.replaceRange.startLine;
134053
+ const endLine = Math.min(pattern.replaceRange.endLine, lines.length - 1);
134054
+ const replacementLines = generateRegionRefactor(pattern).split("\n");
134055
+ lines.splice(startLine, endLine - startLine + 1, ...replacementLines);
134056
+ const result = lines.join("\n");
134057
+ return text.endsWith("\n") && !result.endsWith("\n") ? `${result}
134058
+ ` : result;
134059
+ }
134060
+
133773
134061
  // src/cli.ts
133774
134062
  function handleImportResolutionErrors(errors, colors) {
133775
134063
  const { blockingErrors, warningErrors } = splitImportResolutionErrors(errors);
@@ -133884,7 +134172,9 @@ function parseArgs(args) {
133884
134172
  noRedact: false,
133885
134173
  tagFilters: [],
133886
134174
  tagsFilter: [],
133887
- insecure: false
134175
+ insecure: false,
134176
+ refactorRegionPattern: false,
134177
+ writeRefactor: false
133888
134178
  };
133889
134179
  for (let i = 0; i < args.length; i++) {
133890
134180
  const arg = args[i];
@@ -133902,6 +134192,10 @@ function parseArgs(args) {
133902
134192
  options.timeout = parseInt(args[++i], 10) * 1e3;
133903
134193
  } else if (arg === "--insecure") {
133904
134194
  options.insecure = true;
134195
+ } else if (arg === "--refactor-region-pattern" || arg === "--refactor-nornenv-region-pattern") {
134196
+ options.refactorRegionPattern = true;
134197
+ } else if (arg === "--write") {
134198
+ options.writeRefactor = true;
133905
134199
  } else if (arg === "--no-fail") {
133906
134200
  options.failOnError = false;
133907
134201
  } else if (arg === "--no-redact") {
@@ -133957,6 +134251,9 @@ Options:
133957
134251
  -o, --output-dir <dir> Output directory for reports (auto-generates timestamped files)
133958
134252
  --tag <filter> Filter sequences by tag (AND logic, can be repeated)
133959
134253
  --tags <filters> Filter sequences by tags (OR logic, comma-separated)
134254
+ --refactor-region-pattern
134255
+ Refactor a flat .nornenv STAGE_REGION matrix to templates
134256
+ --write Apply --refactor-region-pattern instead of printing result
133960
134257
  -h, --help Show this help message
133961
134258
 
133962
134259
  Report Generation:
@@ -134000,11 +134297,62 @@ Examples:
134000
134297
  norn api-tests.norn --html report.html # Generate HTML report (explicit)
134001
134298
  norn api-tests.norn --insecure # Allow self-signed/local TLS certs
134002
134299
  norn api-tests.norn --no-redact # Show all data (no redaction)
134300
+ norn .nornenv --refactor-region-pattern # Print refactored .nornenv
134301
+ norn .nornenv --refactor-region-pattern --write
134003
134302
  norn secrets keygen --name team-main # Generate shared key and cache locally
134004
134303
  norn secrets import-key --kid team-main # Save shared key from your vault
134005
134304
  norn secrets audit . # Fail if plaintext secrets are committed
134006
134305
  `);
134007
134306
  }
134307
+ function formatRegionPatternSummary(pattern) {
134308
+ const { liftedToStage, liftedToRegion, leafSpecific, skippedConnectionStrings } = pattern.summary;
134309
+ const lines = [
134310
+ `Detected ${pattern.cells.length} envs across ${pattern.stages.length} stages x ${pattern.regions.length} regions.`,
134311
+ `Lifted ${liftedToStage} vars to stage templates (${pattern.stages.join(", ")}).`,
134312
+ `Lifted ${liftedToRegion} vars to region templates (${pattern.regions.join(", ")}).`,
134313
+ `Kept ${leafSpecific} vars leaf-specific.`
134314
+ ];
134315
+ if (skippedConnectionStrings > 0) {
134316
+ lines.push(`Kept ${skippedConnectionStrings} connection-string var${skippedConnectionStrings === 1 ? "" : "s"} in leaf envs.`);
134317
+ }
134318
+ return lines;
134319
+ }
134320
+ function runNornenvRegionRefactor(filePath, options) {
134321
+ const content = fs19.readFileSync(filePath, "utf-8");
134322
+ const config2 = parseEnvFile(content, filePath);
134323
+ const pattern = detectRegionPattern(config2, content);
134324
+ if (!pattern) {
134325
+ if (options.output === "json") {
134326
+ console.log(JSON.stringify({ success: false, changed: false, error: "No region pattern detected" }, null, 2));
134327
+ } else {
134328
+ console.error("No region pattern detected in this .nornenv file.");
134329
+ }
134330
+ process.exit(1);
134331
+ }
134332
+ const refactored = applyRegionRefactorToText(content, pattern);
134333
+ if (options.writeRefactor) {
134334
+ fs19.writeFileSync(filePath, refactored, "utf-8");
134335
+ }
134336
+ if (options.output === "json") {
134337
+ console.log(JSON.stringify({
134338
+ success: true,
134339
+ changed: refactored !== content,
134340
+ file: filePath,
134341
+ summary: pattern.summary,
134342
+ stages: pattern.stages,
134343
+ regions: pattern.regions,
134344
+ output: options.writeRefactor ? void 0 : refactored
134345
+ }, null, 2));
134346
+ } else if (options.writeRefactor) {
134347
+ for (const line2 of formatRegionPatternSummary(pattern)) {
134348
+ console.log(line2);
134349
+ }
134350
+ console.log(`Refactored ${filePath}`);
134351
+ } else {
134352
+ console.log(refactored);
134353
+ }
134354
+ process.exit(0);
134355
+ }
134008
134356
  async function runSingleRequest(fileContent, variables, cookieJar, apiDefinitions, filePath, envContext) {
134009
134357
  const lines = fileContent.split("\n");
134010
134358
  const requestLines = [];
@@ -134169,6 +134517,17 @@ async function main() {
134169
134517
  process.exit(1);
134170
134518
  }
134171
134519
  const isDirectory = fs19.statSync(inputPath).isDirectory();
134520
+ if (options.writeRefactor && !options.refactorRegionPattern) {
134521
+ console.error("Error: --write can only be used with --refactor-region-pattern");
134522
+ process.exit(1);
134523
+ }
134524
+ if (options.refactorRegionPattern) {
134525
+ if (isDirectory) {
134526
+ console.error("Error: --refactor-region-pattern requires a specific .nornenv file, not a directory");
134527
+ process.exit(1);
134528
+ }
134529
+ runNornenvRegionRefactor(inputPath, options);
134530
+ }
134172
134531
  let filesToRun;
134173
134532
  if (isDirectory) {
134174
134533
  filesToRun = discoverNornFiles(inputPath);