@taiga-ai/mcp-server 0.10.0-rc.405 → 0.11.0-dev.410

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-rc.405",
3
+ "version": "0.11.0-dev.410",
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
 
@@ -1073,6 +1073,30 @@ describe('MCP server integration', () => {
1073
1073
  expect(parseToolResult(result)).toEqual({ ...run, steps: [step] });
1074
1074
  });
1075
1075
 
1076
+ 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 () => {
1077
+ const tenanted = { ...run, tenantId: 'tenant-1' };
1078
+ const tenantedStep = { ...step, tenantId: 'tenant-1', verification: { tenantId: 'tenant-1', ok: true } };
1079
+ const { client } = await bootMcpPair({
1080
+ capabilities: ['products:read', 'backlog:read'],
1081
+ routes: {
1082
+ [`/v1/builds/${BUILD_ID}`]: { data: tenanted },
1083
+ [`/v1/builds/${BUILD_ID}/steps`]: { data: [tenantedStep] },
1084
+ [`/v1/backlog/${ITEM_ID}`]: { data: { ...item, tenantId: 'tenant-1' } },
1085
+ },
1086
+ });
1087
+
1088
+ const build = parseToolResult(
1089
+ await client.callTool({ name: 'taiga_get_build', arguments: { buildId: BUILD_ID } })
1090
+ );
1091
+ expect(build).toEqual({ ...run, steps: [{ ...step, verification: { ok: true } }] });
1092
+ expect(JSON.stringify(build)).not.toContain('tenantId');
1093
+
1094
+ const got = parseToolResult(
1095
+ await client.callTool({ name: 'taiga_get_backlog_item', arguments: { itemId: ITEM_ID } })
1096
+ );
1097
+ expect(got).toEqual(item);
1098
+ });
1099
+
1076
1100
  it('should refuse a fractional limit before any request, as the API would after one', async () => {
1077
1101
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ['backlog:read', 'products:read'] });
1078
1102
  const before = fetchSpy.mock.calls.length;
@@ -2638,7 +2662,24 @@ describe('MCP server integration', () => {
2638
2662
  backlogItemId: ITEM_ID,
2639
2663
  version: 1,
2640
2664
  status: 'active',
2641
- steps: [{ id: 'step-1', planId: PLAN_ID, title: 'Add the route', writes: ['src/routes/login.ts'] }],
2665
+ tenantId: 'tenant-1',
2666
+ steps: [
2667
+ {
2668
+ id: 'step-1',
2669
+ planId: PLAN_ID,
2670
+ tenantId: 'tenant-1',
2671
+ orderIndex: 1,
2672
+ title: 'Add the route',
2673
+ description: 'Three paragraphs of planner prose the summary must not carry',
2674
+ layer: 'api',
2675
+ dependencies: [],
2676
+ filePaths: ['src/routes/login.ts'],
2677
+ writes: ['src/routes/login.ts'],
2678
+ reads: [],
2679
+ createsResources: ['aws_rds_cluster.main'],
2680
+ createdAt: '2026-10-08T00:00:00.000Z',
2681
+ },
2682
+ ],
2642
2683
  };
2643
2684
  const run = {
2644
2685
  id: BUILD_ID,
@@ -2876,6 +2917,58 @@ describe('MCP server integration', () => {
2876
2917
  expect(outcomeOf(result)).toMatchObject({ state: 'todo' });
2877
2918
  });
2878
2919
 
2920
+ it('should archive an initiative where it stands and bring it back, through the same move', async () => {
2921
+ const { client, fetchSpy } = await bootMcpPair({
2922
+ capabilities: ALL_CAPABILITIES,
2923
+ routes: {
2924
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: {
2925
+ data: { ...item, state: 'canceled', status: 'canceled', archivedAt: '2026-10-08T06:00:00.000Z' },
2926
+ },
2927
+ },
2928
+ });
2929
+
2930
+ const filed = await client.callTool({
2931
+ name: 'taiga_move_backlog_item',
2932
+ arguments: { itemId: ITEM_ID, state: 'canceled', archived: true },
2933
+ });
2934
+
2935
+ expect(filed.isError).toBeFalsy();
2936
+ expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'canceled', archived: true });
2937
+ expect(outcomeOf(filed)).toMatchObject({ state: 'canceled', archivedAt: '2026-10-08T06:00:00.000Z' });
2938
+
2939
+ fetchSpy.mockClear();
2940
+ vi.stubGlobal('fetch', fetchSpy);
2941
+ await client.callTool({
2942
+ name: 'taiga_move_backlog_item',
2943
+ arguments: { itemId: ITEM_ID, state: 'todo', archived: false },
2944
+ });
2945
+ expect(sentBody(fetchSpy, 'PUT', `/backlog/${ITEM_ID}/state`)).toEqual({ state: 'todo', archived: false });
2946
+ });
2947
+
2948
+ it("should pass on the API's refusal to archive an initiative that is on the line", async () => {
2949
+ const { client } = await bootMcpPair({
2950
+ capabilities: ALL_CAPABILITIES,
2951
+ routes: {
2952
+ [`PUT /v1/backlog/${ITEM_ID}/state`]: refusal(
2953
+ 409,
2954
+ 'CONFLICT',
2955
+ 'Take this initiative off the line before archiving it.'
2956
+ ),
2957
+ },
2958
+ });
2959
+
2960
+ const result = await client.callTool({
2961
+ name: 'taiga_move_backlog_item',
2962
+ arguments: { itemId: ITEM_ID, state: 'done', archived: true },
2963
+ });
2964
+
2965
+ expect(result.isError).toBe(true);
2966
+ expect(outcomeOf(result)).toEqual({
2967
+ error: 'CONFLICT',
2968
+ message: 'Take this initiative off the line before archiving it.',
2969
+ });
2970
+ });
2971
+
2879
2972
  it('should offer exactly the columns a person may drop into, and refuse build', async () => {
2880
2973
  const { client, fetchSpy } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
2881
2974
 
@@ -2936,7 +3029,32 @@ describe('MCP server integration', () => {
2936
3029
  });
2937
3030
 
2938
3031
  const found = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID } });
2939
- expect(outcomeOf(found)).toEqual(plan);
3032
+ // A summary by default: the step's prose, timestamps and planId stay behind.
3033
+ expect(outcomeOf(found)).toEqual({
3034
+ id: PLAN_ID,
3035
+ backlogItemId: ITEM_ID,
3036
+ version: 1,
3037
+ status: 'active',
3038
+ steps: [
3039
+ {
3040
+ id: 'step-1',
3041
+ orderIndex: 1,
3042
+ title: 'Add the route',
3043
+ layer: 'api',
3044
+ dependencies: [],
3045
+ writes: ['src/routes/login.ts'],
3046
+ reads: [],
3047
+ createsResources: ['aws_rds_cluster.main'],
3048
+ },
3049
+ ],
3050
+ });
3051
+
3052
+ const full = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: ITEM_ID, full: true } });
3053
+ const { tenantId: _t, ...planWithoutTenant } = plan;
3054
+ expect(outcomeOf(full)).toEqual({
3055
+ ...planWithoutTenant,
3056
+ steps: plan.steps.map(({ tenantId: _s, ...step }) => step),
3057
+ });
2940
3058
 
2941
3059
  const none = await client.callTool({ name: 'taiga_get_plan', arguments: { itemId: OTHER_ITEM } });
2942
3060
  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
  },
@@ -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',
@@ -1,3 +1,6 @@
1
+ import { fromMarkdown } from 'mdast-util-from-markdown';
2
+ import { toMarkdown } from 'mdast-util-to-markdown';
3
+ import type { Heading, Node, Parent, PhrasingContent } from 'mdast';
1
4
  import { isRecord, sectionContent, sectionsOf } from './sections.js';
2
5
 
3
6
  /**
@@ -29,7 +32,7 @@ export function renderDocument(doc: Record<string, unknown>): { mimeType: string
29
32
  for (const id of Object.keys(sections)) {
30
33
  const content = sectionContent(sections, id);
31
34
  if (content === null) continue;
32
- lines.push('', `## ${sectionTitle(sections[id], id)}`, '', content.trim());
35
+ lines.push('', `## ${sectionTitle(sections[id], id)}`, '', nestHeadings(content.trim()));
33
36
  }
34
37
  const { sections: _sections, ...rest } = data;
35
38
  const other = Object.fromEntries(Object.entries(rest).filter(([, value]) => !isEmpty(value)));
@@ -57,6 +60,64 @@ const isEmpty = (value: unknown) =>
57
60
  (Array.isArray(value) && value.length === 0) ||
58
61
  (isRecord(value) && Object.keys(value).length === 0);
59
62
 
63
+ /**
64
+ * A section body's own headings, one level down, so they sit under the
65
+ * section heading instead of beside it: a derived document writes each
66
+ * section with its own H2. A CommonMark parser says what is a heading, so
67
+ * fenced code, indented code, list items and block quotes are read as a
68
+ * reader's renderer reads them, and a heading-shaped line inside an HTML
69
+ * block is HTML, not a heading, and stays. Every heading lands at least one
70
+ * level under the section's H2, so an H1 and an H2 both become H3 and the
71
+ * rest move one down, H6 at most: an ATX heading (the # kind, an empty one
72
+ * included) takes the marks it needs; a setext heading (underlined)
73
+ * becomes the ATX heading of that depth, serialized from its parsed
74
+ * content (so a quote's prefix, a hard line break or a trailing # run are
75
+ * handled as Markdown, not as text). The edits go by the parser's
76
+ * character offsets, so no line counting of ours can disagree with its own.
77
+ */
78
+ export function nestHeadings(body: string): string {
79
+ const edits: { start: number; end: number; text: string }[] = [];
80
+ // An explicit stack, and no spread into push: a body of thousands of
81
+ // nested quotes, or a list of very many items, must not overflow a walk.
82
+ const pending: Node[] = [fromMarkdown(body)];
83
+ while (pending.length > 0) {
84
+ const node = pending.pop()!;
85
+ if (
86
+ node.type === 'heading' &&
87
+ node.position?.start.offset !== undefined &&
88
+ node.position.end.offset !== undefined
89
+ ) {
90
+ const start = node.position.start.offset;
91
+ const end = node.position.end.offset;
92
+ const heading = node as Heading;
93
+ const target = nestedDepth(heading.depth);
94
+ const atx = /^#{1,6}(\s|$)/.test(body.slice(start, start + 7));
95
+ if (atx && target > heading.depth) edits.push({ start, end: start, text: '#'.repeat(target - heading.depth) });
96
+ else if (!atx) edits.push({ start, end, text: atxOf(target, heading.children) });
97
+ }
98
+ if ('children' in node) for (const child of (node as Parent).children) pending.push(child);
99
+ }
100
+ let out = body;
101
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
102
+ out = `${out.slice(0, edit.start)}${edit.text}${out.slice(edit.end)}`;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** Under the section's H2: at least H3, one level down otherwise, H6 at most. */
108
+ const nestedDepth = (depth: number): Heading['depth'] => Math.min(6, Math.max(3, depth + 1)) as Heading['depth'];
109
+
110
+ /** An ATX heading of this depth from parsed content, its line breaks turned into spaces: an ATX heading is one line. */
111
+ function atxOf(depth: Heading['depth'], children: PhrasingContent[]): string {
112
+ const oneLine = (nodes: PhrasingContent[]): PhrasingContent[] =>
113
+ nodes.map((child) => {
114
+ if (child.type === 'break') return { type: 'text', value: ' ' };
115
+ if (child.type === 'text') return { ...child, value: child.value.replace(/\r\n|\r|\n/g, ' ') };
116
+ return 'children' in child ? { ...child, children: oneLine(child.children as PhrasingContent[]) } : child;
117
+ });
118
+ return toMarkdown({ type: 'heading', depth, children: oneLine(children) }).trimEnd();
119
+ }
120
+
60
121
  /** The section's title when it carries one, else its id. */
61
122
  function sectionTitle(section: unknown, id: string): string {
62
123
  return isRecord(section) && typeof section.title === 'string' && section.title.trim() ? section.title.trim() : id;