@taiga-ai/mcp-server 0.10.0 → 0.11.0-dev.411

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taiga-ai/mcp-server",
3
- "version": "0.10.0",
3
+ "version": "0.11.0-dev.411",
4
4
  "description": "MCP server for Taiga -- gives AI assistants access to documents and products",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,7 +27,10 @@
27
27
  },
28
28
  "devDependencies": {
29
29
  "@tsconfig/strictest": "^2.0.8",
30
+ "@types/mdast": "^4.0.4",
30
31
  "@types/node": "^22.20.1",
32
+ "mdast-util-from-markdown": "^2.1.0",
33
+ "mdast-util-to-markdown": "^2.2.0",
31
34
  "rolldown": "^1.2.3",
32
35
  "typescript": "^7.0.2",
33
36
  "vitest": "^5.0.0"
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { readFileSync } from 'node:fs';
3
- import { TaigaApiError, TaigaClient } from './client.js';
3
+ import { TaigaApiError, TaigaClient, withoutTenant } from './client.js';
4
4
 
5
5
  // Read independently of src/version.ts, so the test checks the real file.
6
6
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string };
@@ -150,6 +150,46 @@ describe('TaigaClient', () => {
150
150
  // Introspection -- the foundation for capability-gated tool registration
151
151
  // -------------------------------------------------------------------------
152
152
 
153
+ describe('withoutTenant', () => {
154
+ it('drops tenantId at every depth and keeps an own __proto__ key as data', () => {
155
+ const parsed = JSON.parse(
156
+ '{"__proto__": {"x": 1}, "tenantId": "t", "a": {"tenantId": "t", "b": [{"tenantId": "t", "c": 1}, 2, null]}, "d": "s"}'
157
+ ) as Record<string, unknown>;
158
+
159
+ const out = withoutTenant(parsed) as Record<string, unknown>;
160
+
161
+ expect(JSON.stringify(out)).not.toContain('tenantId');
162
+ expect(out.a).toEqual({ b: [{ c: 1 }, 2, null] });
163
+ expect(out.d).toBe('s');
164
+ expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true);
165
+ expect(Object.getPrototypeOf(out)).toBe(Object.prototype);
166
+ expect((out as { x?: unknown }).x).toBeUndefined();
167
+ });
168
+
169
+ it('walks a body nested deeper than a recursive copy could', () => {
170
+ const depth = 20_000;
171
+ const nested = JSON.parse(`${'{"a":['.repeat(depth)}{"tenantId":"t","v":1}${']}'.repeat(depth)}`) as unknown;
172
+
173
+ const out = withoutTenant(nested);
174
+
175
+ // Walked, not stringified: JSON.stringify recurses and would overflow where the copy did not.
176
+ let cursor: unknown = out;
177
+ for (let level = 0; level < depth; level += 1) {
178
+ expect(Object.keys(cursor as object)).toEqual(['a']);
179
+ cursor = (cursor as { a: unknown[] }).a[0];
180
+ }
181
+ expect(cursor).toEqual({ v: 1 });
182
+ });
183
+
184
+ it('keeps key order, which is the order sections render in', () => {
185
+ const out = withoutTenant({ z: 1, tenantId: 't', a: { y: 2, x: [3, { tenantId: 't', w: 4 }] }, m: 5 });
186
+
187
+ expect(Object.keys(out)).toEqual(['z', 'a', 'm']);
188
+ expect(Object.keys(out.a)).toEqual(['y', 'x']);
189
+ expect(out.a.x).toEqual([3, { w: 4 }]);
190
+ });
191
+ });
192
+
153
193
  describe('introspection', () => {
154
194
  it('should return and store the introspection result', async () => {
155
195
  const expected = {
package/src/client.ts CHANGED
@@ -2,11 +2,45 @@ import type { ApiKeyIntrospection } from '@taiga/types';
2
2
  import { SERVER_VERSION } from './version.js';
3
3
 
4
4
  /**
5
- * A non-2xx answer from the Taiga API. Carries the status and the API's own
6
- * `code` and `message` so a tool can hand the model something it can act on
7
- * (a 400 from the publish gate names the section that still holds template
8
- * residue) instead of one opaque string.
5
+ * The same value without any `tenantId`, at any depth. Build run and step
6
+ * rows carry one; the key is scoped to one tenant already, so the id is
7
+ * never something the model acts on. Applied to every response here, once,
8
+ * so no tool, resource or prompt can hand it over by forgetting to; only
9
+ * the key's own introspection keeps it, typed and never handed over.
10
+ *
11
+ * An explicit stack rather than recursion: a document body a person stored
12
+ * can nest as deep as JSON.parse allows, and a walk must not overflow where
13
+ * the parse did not. Every key is defined as a data property, so an own
14
+ * "__proto__" key stays a key instead of becoming the copy's prototype.
9
15
  */
16
+ export function withoutTenant<T>(value: T): T {
17
+ const root: { out?: unknown } = {};
18
+ // Siblings keep their order and nested values are copied in full: see the loop.
19
+ const pending: { source: unknown; target: object; key: string | number }[] = [
20
+ { source: value, target: root, key: 'out' },
21
+ ];
22
+ while (pending.length > 0) {
23
+ const { source, target, key } = pending.pop()!;
24
+ let copy: unknown = source;
25
+ // Children are pushed last-first so they pop first-first: key order is
26
+ // meaning in a section map (the order sections render in) and must survive.
27
+ if (Array.isArray(source)) {
28
+ copy = new Array(source.length);
29
+ for (let index = source.length - 1; index >= 0; index -= 1) {
30
+ pending.push({ source: source[index], target: copy as object, key: index });
31
+ }
32
+ } else if (source !== null && typeof source === 'object') {
33
+ copy = {};
34
+ const entries = Object.entries(source as Record<string, unknown>).filter(([inner]) => inner !== 'tenantId');
35
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
36
+ pending.push({ source: entries[index]![1], target: copy as object, key: entries[index]![0] });
37
+ }
38
+ }
39
+ Object.defineProperty(target, key, { value: copy, enumerable: true, writable: true, configurable: true });
40
+ }
41
+ return root.out as T;
42
+ }
43
+
10
44
  export class TaigaApiError extends Error {
11
45
  readonly status: number;
12
46
  readonly code: string | undefined;
@@ -87,7 +121,7 @@ export class TaigaClient {
87
121
  this.apiKey = opts.apiKey;
88
122
  }
89
123
 
90
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
124
+ private async request<T>(method: string, path: string, body?: unknown, keep?: { tenantId: true }): Promise<T> {
91
125
  const url = `${this.baseUrl}/v1${path}`;
92
126
  const headers: Record<string, string> = {
93
127
  Authorization: `Bearer ${this.apiKey}`,
@@ -108,7 +142,8 @@ export class TaigaClient {
108
142
  throw new TaigaApiError(method, path, response.status, text);
109
143
  }
110
144
 
111
- return response.json() as Promise<T>;
145
+ const parsed = (await response.json()) as T;
146
+ return keep ? parsed : withoutTenant(parsed);
112
147
  }
113
148
 
114
149
  // ---------------------------------------------------------------------------
@@ -116,7 +151,10 @@ export class TaigaClient {
116
151
  // ---------------------------------------------------------------------------
117
152
 
118
153
  async introspect(): Promise<ApiKeyIntrospection> {
119
- const result = await this.request<{ data: ApiKeyIntrospection }>('GET', '/auth/introspect');
154
+ // The key's own identity keeps its tenant: typed so, and never handed to the model.
155
+ const result = await this.request<{ data: ApiKeyIntrospection }>('GET', '/auth/introspect', undefined, {
156
+ tenantId: true,
157
+ });
120
158
  this.introspection = result.data;
121
159
  return result.data;
122
160
  }
@@ -312,7 +350,10 @@ export class TaigaClient {
312
350
  }
313
351
 
314
352
  /** A person's move on the line board: the column dropped into, and what to follow there. */
315
- async transitionInitiative(itemId: string, body: { state: string; afterId?: string | null | undefined }) {
353
+ async transitionInitiative(
354
+ itemId: string,
355
+ body: { state: string; afterId?: string | null | undefined; archived?: boolean | undefined }
356
+ ) {
316
357
  return this.request<{ data: unknown }>('PUT', `/backlog/${seg(itemId)}/state`, body);
317
358
  }
318
359
 
@@ -100,10 +100,13 @@ function sentBody(fetchSpy: ReturnType<typeof vi.fn>, method: string, pathPart:
100
100
 
101
101
  /** What the API answers when it refuses: the status and the error envelope its error handler sends. */
102
102
  class Refusal {
103
- constructor(
104
- readonly status: number,
105
- readonly body: { error: string; code: string; message: string }
106
- ) {}
103
+ readonly status: number;
104
+ readonly body: { error: string; code: string; message: string };
105
+
106
+ constructor(status: number, body: { error: string; code: string; message: string }) {
107
+ this.status = status;
108
+ this.body = body;
109
+ }
107
110
  }
108
111
  const refusal = (status: number, code: string, message: string) => new Refusal(status, { error: code, code, message });
109
112
 
@@ -1073,6 +1076,30 @@ describe('MCP server integration', () => {
1073
1076
  expect(parseToolResult(result)).toEqual({ ...run, steps: [step] });
1074
1077
  });
1075
1078
 
1079
+ it('should drop tenantId from every response at the client, so a run, its steps, an item and a list row never carry it', async () => {
1080
+ const tenanted = { ...run, tenantId: 'tenant-1' };
1081
+ const tenantedStep = { ...step, tenantId: 'tenant-1', verification: { tenantId: 'tenant-1', ok: true } };
1082
+ const { client } = await bootMcpPair({
1083
+ capabilities: ['products:read', 'backlog:read'],
1084
+ routes: {
1085
+ [`/v1/builds/${BUILD_ID}`]: { data: tenanted },
1086
+ [`/v1/builds/${BUILD_ID}/steps`]: { data: [tenantedStep] },
1087
+ [`/v1/backlog/${ITEM_ID}`]: { data: { ...item, tenantId: 'tenant-1' } },
1088
+ },
1089
+ });
1090
+
1091
+ const build = parseToolResult(
1092
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
1093
+ );
1094
+ expect(build).toEqual({ ...run, steps: [{ ...step, verification: { ok: true } }] });
1095
+ expect(JSON.stringify(build)).not.toContain('tenantId');
1096
+
1097
+ const got = parseToolResult(
1098
+ await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
1099
+ );
1100
+ expect(got).toEqual(item);
1101
+ });
1102
+
1076
1103
  it('should refuse a fractional limit before any request, as the API would after one', async () => {
1077
1104
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ['backlog:read', 'products:read'] });
1078
1105
  const before = fetchSpy.mock.calls.length;
@@ -2638,7 +2665,24 @@ describe('MCP server integration', () => {
2638
2665
  backlogItemId: ITEM_ID,
2639
2666
  version: 1,
2640
2667
  status: 'active',
2641
- steps: [{ id: 'step-1', planId: PLAN_ID, title: 'Add the route', writes: ['src/routes/login.ts'] }],
2668
+ tenantId: 'tenant-1',
2669
+ steps: [
2670
+ {
2671
+ id: 'step-1',
2672
+ planId: PLAN_ID,
2673
+ tenantId: 'tenant-1',
2674
+ orderIndex: 1,
2675
+ title: 'Add the route',
2676
+ description: 'Three paragraphs of planner prose the summary must not carry',
2677
+ layer: 'api',
2678
+ dependencies: [],
2679
+ filePaths: ['src/routes/login.ts'],
2680
+ writes: ['src/routes/login.ts'],
2681
+ reads: [],
2682
+ createsResources: ['aws_rds_cluster.main'],
2683
+ createdAt: '2026-10-08T00:00:00.000Z',
2684
+ },
2685
+ ],
2642
2686
  };
2643
2687
  const run = {
2644
2688
  id: BUILD_ID,
@@ -2876,6 +2920,58 @@ describe('MCP server integration', () => {
2876
2920
  expect(outcomeOf(result)).toMatchObject({ state: 'todo' });
2877
2921
  });
2878
2922
 
2923
+ it('should archive an initiative where it stands and bring it back, through the same move', async () => {
2924
+ const { client, fetchSpy } = await bootMcpPair({
2925
+ capabilities: ALL_CAPABILITIES,
2926
+ routes: {
2927
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: {
2928
+ data: { ...item, state: 'canceled', status: 'canceled', archivedAt: '2026-10-08T06:00:00.000Z' },
2929
+ },
2930
+ },
2931
+ });
2932
+
2933
+ const filed = await client.callTool({
2934
+ name: 'taiga_move_backlog_item',
2935
+ arguments: { itemId: ITEM_ID, state: 'canceled', archived: true },
2936
+ });
2937
+
2938
+ expect(filed.isError).toBeFalsy();
2939
+ expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'canceled', archived: true });
2940
+ expect(outcomeOf(filed)).toMatchObject({ state: 'canceled', archivedAt: '2026-10-08T06:00:00.000Z' });
2941
+
2942
+ fetchSpy.mockClear();
2943
+ vi.stubGlobal('fetch', fetchSpy);
2944
+ await client.callTool({
2945
+ name: 'taiga_move_backlog_item',
2946
+ arguments: { itemId: ITEM_ID, state: 'todo', archived: false },
2947
+ });
2948
+ expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'todo', archived: false });
2949
+ });
2950
+
2951
+ it("should pass on the API's refusal to archive an initiative that is on the line", async () => {
2952
+ const { client } = await bootMcpPair({
2953
+ capabilities: ALL_CAPABILITIES,
2954
+ routes: {
2955
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: refusal(
2956
+ 409,
2957
+ 'CONFLICT',
2958
+ 'Take this initiative off the line before archiving it.'
2959
+ ),
2960
+ },
2961
+ });
2962
+
2963
+ const result = await client.callTool({
2964
+ name: 'taiga_move_backlog_item',
2965
+ arguments: { itemId: ITEM_ID, state: 'done', archived: true },
2966
+ });
2967
+
2968
+ expect(result.isError).toBe(true);
2969
+ expect(outcomeOf(result)).toEqual({
2970
+ error: 'CONFLICT',
2971
+ message: 'Take this initiative off the line before archiving it.',
2972
+ });
2973
+ });
2974
+
2879
2975
  it('should offer exactly the columns a person may drop into, and refuse build', async () => {
2880
2976
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2881
2977
 
@@ -2936,7 +3032,32 @@ describe('MCP server integration', () => {
2936
3032
  });
2937
3033
 
2938
3034
  const found = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID } });
2939
- expect(outcomeOf(found)).toEqual(plan);
3035
+ // A summary by default: the step's prose, timestamps and planId stay behind.
3036
+ expect(outcomeOf(found)).toEqual({
3037
+ id: PLAN_ID,
3038
+ backlogItemId: ITEM_ID,
3039
+ version: 1,
3040
+ status: 'active',
3041
+ steps: [
3042
+ {
3043
+ id: 'step-1',
3044
+ orderIndex: 1,
3045
+ title: 'Add the route',
3046
+ layer: 'api',
3047
+ dependencies: [],
3048
+ writes: ['src/routes/login.ts'],
3049
+ reads: [],
3050
+ createsResources: ['aws_rds_cluster.main'],
3051
+ },
3052
+ ],
3053
+ });
3054
+
3055
+ const full = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID, full: true } });
3056
+ const { tenantId: _t, ...planWithoutTenant } = plan;
3057
+ expect(outcomeOf(full)).toEqual({
3058
+ ...planWithoutTenant,
3059
+ steps: plan.steps.map(({ tenantId: _s, ...step }) => step),
3060
+ });
2940
3061
 
2941
3062
  const none = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER_ITEM } });
2942
3063
  expect(none.isError).toBe(true);
@@ -38,11 +38,25 @@ const ITEM_OUTCOME_FIELDS = [
38
38
  'planningStatus',
39
39
  'needsHumanCause',
40
40
  'needsHumanDetail',
41
+ 'archivedAt',
41
42
  'lifecycle',
42
43
  ] as const;
43
44
 
44
45
  const itemId = z.string().uuid().describe('Backlog item ID -- get from taiga_list_backlog_items');
45
46
 
47
+ /** The plan without its steps' prose: enough to approve it or say what a run will do. */
48
+ const PLAN_SUMMARY_FIELDS = ['id', 'backlogItemId', 'version', 'status', 'createdAt'] as const;
49
+ const PLAN_STEP_SUMMARY_FIELDS = [
50
+ 'id',
51
+ 'orderIndex',
52
+ 'title',
53
+ 'layer',
54
+ 'dependencies',
55
+ 'writes',
56
+ 'reads',
57
+ 'createsResources',
58
+ ] as const;
59
+
46
60
  const outcome = (item: unknown) => toolResult(pickFields([item as Record<string, unknown>], ITEM_OUTCOME_FIELDS)[0]);
47
61
 
48
62
  /**
@@ -125,17 +139,27 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
125
139
  {
126
140
  title: 'Get the active plan',
127
141
  description:
128
- "Get an initiative's active plan with its steps: what each step changes, the files it writes and reads, " +
129
- 'its layer and dependencies. Read this before taiga_approve_plan or taiga_reject_plan, and to see what ' +
130
- 'a run will do. NOT_FOUND when the initiative has no active plan yet (planningStatus says whether one ' +
131
- 'is being generated).',
132
- inputSchema: { itemId },
142
+ "Get an initiative's active plan with its steps, in order: each step's title, layer, dependencies " +
143
+ '(the orderIndex of the steps it builds on), the files it writes and reads and the resources it creates. ' +
144
+ 'That is enough to ' +
145
+ 'approve or reject it (taiga_approve_plan, taiga_reject_plan) and to say what a run will do; pass ' +
146
+ "full: true for each step's complete instructions, several thousand tokens on a large plan. NOT_FOUND " +
147
+ 'when the initiative has no active plan yet (planningStatus says whether one is being generated).',
148
+ inputSchema: {
149
+ itemId,
150
+ full: z.boolean().optional().describe("Carry each step's complete instructions (default false: a summary)"),
151
+ },
133
152
  annotations: READ_ONLY,
134
153
  },
135
154
  (params) =>
136
155
  runTool(async () => {
137
- const result = await client.getActivePlan(params.itemId);
138
- return toolResult(result.data);
156
+ const plan = (await client.getActivePlan(params.itemId)).data as Record<string, unknown>;
157
+ if (params.full) return toolResult(plan);
158
+ const steps = Array.isArray(plan.steps) ? (plan.steps as Record<string, unknown>[]) : [];
159
+ return toolResult({
160
+ ...pickFields([plan], PLAN_SUMMARY_FIELDS)[0],
161
+ steps: pickFields(steps, PLAN_STEP_SUMMARY_FIELDS),
162
+ });
139
163
  })
140
164
  );
141
165
  }
@@ -186,7 +210,10 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
186
210
  'initiative state machine refuses a move that is not allowed (CONFLICT: build cannot be dropped ' +
187
211
  'into, an initiative being planned cannot leave plan). A move into queue or plan starts paid work and ' +
188
212
  'needs products:write on the key; a key without it is refused here. Ask the person before a move that ' +
189
- `starts work (queue, plan) or closes a pull request (${PR_CLOSING_STATES.join(', ')} from build).`,
213
+ `starts work (queue, plan) or closes a pull request (${PR_CLOSING_STATES.join(', ')} from build). ` +
214
+ 'archived: true files the initiative where it stands (pass its current column as state; from any column ' +
215
+ 'off the line, never from plan or build) and archived: false brings it back; an archived initiative is left ' +
216
+ 'out of lists unless includeArchived asks for it.',
190
217
  inputSchema: {
191
218
  itemId,
192
219
  state: z.enum(INITIATIVE_TRANSITION_STATES).describe('The column to move into'),
@@ -196,6 +223,10 @@ function registerBacklogWrites(server: McpServer, client: TaigaClient) {
196
223
  .nullable()
197
224
  .optional()
198
225
  .describe('The initiative to follow in that column; null for the head; omit to keep rank order'),
226
+ archived: z
227
+ .boolean()
228
+ .optional()
229
+ .describe('true files the initiative where it stands, false brings it back; omit to leave as is'),
199
230
  },
200
231
  annotations: OPERATE,
201
232
  },
@@ -85,11 +85,11 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
85
85
  * "MCP error -32602:" and the client would prefix it again.
86
86
  */
87
87
  export class RequestError extends Error {
88
- constructor(
89
- readonly code: number,
90
- message: string
91
- ) {
88
+ readonly code: number;
89
+
90
+ constructor(code: number, message: string) {
92
91
  super(message);
92
+ this.code = code;
93
93
  this.name = 'RequestError';
94
94
  }
95
95
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { fence, renderDocument } from './markdown.js';
2
+ import { fence, nestHeadings, renderDocument } from './markdown.js';
3
3
 
4
4
  describe('fence', () => {
5
5
  it('is one backtick longer than the longest run inside the body, four at least', () => {
@@ -48,6 +48,172 @@ describe('renderDocument', () => {
48
48
  );
49
49
  });
50
50
 
51
+ it('tracks fences as CommonMark does: indented, longer closers, info strings, and headings indented up to three spaces', () => {
52
+ const body = [
53
+ '````',
54
+ '```',
55
+ '# inside a four-tick fence: a three-tick line does not close it',
56
+ '`````',
57
+ '## after a five-tick closer',
58
+ ' ```sh',
59
+ '# indented opener',
60
+ ' ```',
61
+ ' ## indented heading',
62
+ ' # four spaces is code, not a heading',
63
+ '```',
64
+ '```sh',
65
+ '# an opener-with-info inside an open fence is not its closer',
66
+ '```',
67
+ '## out again',
68
+ '```json``` is not a fence opener',
69
+ '## still a heading',
70
+ '~~~',
71
+ '```',
72
+ '# backticks inside a tilde fence',
73
+ '~~~',
74
+ '## last',
75
+ ].join('\n');
76
+
77
+ expect(nestHeadings(body)).toBe(
78
+ [
79
+ '````',
80
+ '```',
81
+ '# inside a four-tick fence: a three-tick line does not close it',
82
+ '`````',
83
+ '### after a five-tick closer',
84
+ ' ```sh',
85
+ '# indented opener',
86
+ ' ```',
87
+ ' ### indented heading',
88
+ ' # four spaces is code, not a heading',
89
+ '```',
90
+ '```sh',
91
+ '# an opener-with-info inside an open fence is not its closer',
92
+ '```',
93
+ '### out again',
94
+ '```json``` is not a fence opener',
95
+ '### still a heading',
96
+ '~~~',
97
+ '```',
98
+ '# backticks inside a tilde fence',
99
+ '~~~',
100
+ '### last',
101
+ ].join('\n')
102
+ );
103
+ });
104
+
105
+ it("edits by the parser's offsets: CRLF and lone CR line endings, a tab after a container marker, an empty heading, an HTML block, deep nesting", () => {
106
+ expect(nestHeadings('```\r\n# in\r\n```\r\n# out')).toBe('```\r\n# in\r\n```\r\n### out');
107
+ expect(nestHeadings('x\ry\n# h\n## i')).toBe('x\ry\n### h\n### i');
108
+ expect(nestHeadings('>\t# q\n-\t# x')).toBe('>\t### q\n-\t### x');
109
+ expect(nestHeadings('#\n##\n# x')).toBe('###\n###\n### x');
110
+ // A heading-shaped line inside an HTML block is HTML to a CommonMark reader, so it stays.
111
+ expect(nestHeadings('<details>\n<summary>s</summary>\n## inside\n</details>\n\n## after')).toBe(
112
+ '<details>\n<summary>s</summary>\n## inside\n</details>\n\n### after'
113
+ );
114
+ // Deeper than a recursive walk could take: nested quotes, which the
115
+ // parser handles in milliseconds (nested list markers it does not).
116
+ const deep = `${'> '.repeat(20_000)}# deep`;
117
+ expect(nestHeadings(deep)).toBe(`${'> '.repeat(20_000)}### deep`);
118
+ });
119
+
120
+ it('turns a setext heading into the ATX heading one level down, inside a quote too, and leaves a thematic break alone', () => {
121
+ expect(nestHeadings('> Quoted\n> ---\ntext\n\n---\n\nAfter\n===')).toBe('> ### Quoted\ntext\n\n---\n\n### After');
122
+ // Serialized from the parsed content: a quote's prefix on a continuation
123
+ // line, a hard line break, a leading # that is not an ATX marker, a
124
+ // trailing # run that would read as a closing sequence, inline markup.
125
+ expect(nestHeadings('> A\n> B\n> ---')).toBe('> ### A B');
126
+ expect(nestHeadings('> > A\n> > B\n> > ===')).toBe('> > ### A B');
127
+ expect(nestHeadings('Title\\\ntwo\n---')).toBe('### Title two');
128
+ expect(nestHeadings('#1 priority\n===')).toBe('### #1 priority');
129
+ expect(nestHeadings('Title ##\n---')).toBe('### Title #\\#');
130
+ expect(nestHeadings('*em* and `code` [l](u)\n===')).toBe('### *em* and `code` [l](u)');
131
+ });
132
+
133
+ it('reads containers as CommonMark does: a fence in a list item, headings in list items and quotes, a setext heading', () => {
134
+ const body = [
135
+ '- ```md',
136
+ ' # literal inside a fence that a list item opened',
137
+ ' ```',
138
+ '- ## in a list item',
139
+ '> ## quoted',
140
+ '> # indented code inside a quote',
141
+ 'Setext',
142
+ '======',
143
+ 'Two line',
144
+ 'setext two',
145
+ '---',
146
+ '## atx after',
147
+ ].join('\n');
148
+
149
+ expect(nestHeadings(body)).toBe(
150
+ [
151
+ '- ```md',
152
+ ' # literal inside a fence that a list item opened',
153
+ ' ```',
154
+ '- ### in a list item',
155
+ '> ### quoted',
156
+ '> # indented code inside a quote',
157
+ '### Setext',
158
+ '### Two line setext two',
159
+ '### atx after',
160
+ ].join('\n')
161
+ );
162
+ });
163
+
164
+ it("nests a section body's own headings under the section heading, leaving fenced code alone", () => {
165
+ const rendered = renderDocument({
166
+ name: 'Threat Model',
167
+ type: 'threat_model',
168
+ data: {
169
+ sections: {
170
+ overview: {
171
+ content: [
172
+ '# Overview',
173
+ 'Text.',
174
+ '## Attack vectors',
175
+ '##### Deep',
176
+ '###### Deepest stays',
177
+ '```md',
178
+ '# not a heading',
179
+ '```',
180
+ '~~~',
181
+ '## nor this',
182
+ '~~~',
183
+ '## After the fences',
184
+ '#hashtag is not a heading',
185
+ ].join('\n'),
186
+ },
187
+ },
188
+ },
189
+ });
190
+
191
+ expect(rendered.text).toBe(
192
+ [
193
+ '# Threat Model',
194
+ '',
195
+ 'type: threat_model',
196
+ '',
197
+ '## overview',
198
+ '',
199
+ '### Overview',
200
+ 'Text.',
201
+ '### Attack vectors',
202
+ '###### Deep',
203
+ '###### Deepest stays',
204
+ '```md',
205
+ '# not a heading',
206
+ '```',
207
+ '~~~',
208
+ '## nor this',
209
+ '~~~',
210
+ '### After the fences',
211
+ '#hashtag is not a heading',
212
+ '',
213
+ ].join('\n')
214
+ );
215
+ });
216
+
51
217
  it('carries what sits beside the sections as JSON, so nothing is lost', () => {
52
218
  const rendered = renderDocument({
53
219
  name: 'Design Standards',