@hominis/fireforge 0.10.1 → 0.11.1

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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +6 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Diffs projected lint issues against the baseline and returns those
3
+ * considered "new" regressions.
4
+ *
5
+ * The equality key is the issue fingerprint when present, otherwise the
6
+ * full `(check, file, message)` triple. Fingerprints are emitted by rules
7
+ * whose message text can drift between otherwise-equivalent runs (for
8
+ * example because the message embeds later-owner filenames or positional
9
+ * detail). Falling back to the full tuple keeps the helper conservative
10
+ * for older/non-fingerprinted rules: if their message changes, we would
11
+ * rather surface a potential regression than silently swallow it.
12
+ *
13
+ * Consumption order within the projected list is stable (the input
14
+ * order is preserved), so when baseline has N issues for a key and
15
+ * projected has N+M for the same key, the *last* M projected issues on
16
+ * that key are reported as regressions. That keeps the reporting
17
+ * deterministic and gives the operator at least one concrete message
18
+ * per regression even when only counts differ.
19
+ *
20
+ * @param baseline - Error-severity issues from the current queue
21
+ * @param projected - Error-severity issues from the projected queue
22
+ * @returns Subset of `projected` not matched by a baseline counterpart
23
+ */
24
+ export function computeProjectedLintRegressions(baseline, projected) {
25
+ const issueKey = (i) => i.fingerprint ?? `${i.check}|${i.file}|${i.message}`;
26
+ const baselineCounts = new Map();
27
+ for (const issue of baseline) {
28
+ const key = issueKey(issue);
29
+ baselineCounts.set(key, (baselineCounts.get(key) ?? 0) + 1);
30
+ }
31
+ const regressions = [];
32
+ const consumedByKey = new Map();
33
+ for (const issue of projected) {
34
+ const key = issueKey(issue);
35
+ const allowed = baselineCounts.get(key) ?? 0;
36
+ const consumed = consumedByKey.get(key) ?? 0;
37
+ if (consumed >= allowed) {
38
+ regressions.push(issue);
39
+ }
40
+ consumedByKey.set(key, consumed + 1);
41
+ }
42
+ return regressions;
43
+ }
44
+ //# sourceMappingURL=lint-projection.js.map
@@ -33,8 +33,10 @@ export interface MachCommandResult {
33
33
  */
34
34
  export declare function runMach(args: string[], engineDir: string, options?: MachOptions): Promise<number>;
35
35
  /**
36
- * Runs a mach command while streaming output to the terminal and capturing it
37
- * for post-run diagnostics.
36
+ * Runs a mach command while streaming output to the terminal and capturing
37
+ * the tail of stdout/stderr for post-run diagnostics. Output beyond
38
+ * {@link CAPTURE_TAIL_LIMIT} is discarded from the head to prevent unbounded
39
+ * memory growth during long-lived processes like Storybook.
38
40
  */
39
41
  export declare function runMachCapture(args: string[], engineDir: string, options?: Omit<MachOptions, 'inherit'>): Promise<MachCommandResult>;
40
42
  /**
@@ -41,8 +41,17 @@ export async function runMach(args, engineDir, options = {}) {
41
41
  return result.exitCode;
42
42
  }
43
43
  /**
44
- * Runs a mach command while streaming output to the terminal and capturing it
45
- * for post-run diagnostics.
44
+ * Maximum bytes retained per stream in `runMachCapture`. Only the tail of
45
+ * output is kept so long-lived processes (Storybook, `mach build`) do not
46
+ * grow the Node heap without bound. 2 MB is enough for post-run error
47
+ * diagnosis while staying well below the 50 MB cap in `createStreamCollector`.
48
+ */
49
+ const CAPTURE_TAIL_LIMIT = 2 * 1024 * 1024;
50
+ /**
51
+ * Runs a mach command while streaming output to the terminal and capturing
52
+ * the tail of stdout/stderr for post-run diagnostics. Output beyond
53
+ * {@link CAPTURE_TAIL_LIMIT} is discarded from the head to prevent unbounded
54
+ * memory growth during long-lived processes like Storybook.
46
55
  */
47
56
  export async function runMachCapture(args, engineDir, options = {}) {
48
57
  const python = await getPython(engineDir);
@@ -55,10 +64,16 @@ export async function runMachCapture(args, engineDir, options = {}) {
55
64
  ...(options.env ? { env: options.env } : {}),
56
65
  onStdout: (data) => {
57
66
  stdout += data;
67
+ if (stdout.length > CAPTURE_TAIL_LIMIT) {
68
+ stdout = stdout.slice(-CAPTURE_TAIL_LIMIT);
69
+ }
58
70
  process.stdout.write(data);
59
71
  },
60
72
  onStderr: (data) => {
61
73
  stderr += data;
74
+ if (stderr.length > CAPTURE_TAIL_LIMIT) {
75
+ stderr = stderr.slice(-CAPTURE_TAIL_LIMIT);
76
+ }
62
77
  process.stderr.write(data);
63
78
  },
64
79
  });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Minimal Markdown pipe-table parser/writer used by token-manager.
3
+ *
4
+ * This is **not** a general Markdown parser. It understands exactly the
5
+ * slice of GitHub-flavoured Markdown that FireForge emits into token docs:
6
+ *
7
+ * - Leading `|` per row, trailing `|` optional.
8
+ * - A separator row of `|---|---|...|` immediately after the header.
9
+ * - Fenced code blocks (``` / ~~~) are recognised and skipped so that
10
+ * literal `|` lines inside code samples are never mistaken for rows.
11
+ * - A table ends on the first non-pipe line (blank line, heading, prose).
12
+ * - Literal `|` and `\` inside a cell are expressed as `\|` and `\\`
13
+ * respectively. The parser treats any other backslash as a literal
14
+ * so prose-style backslashes in cell values (e.g. a Windows path)
15
+ * still round-trip. Unescaping happens at split time; serialization
16
+ * re-applies escapes so round-trips are exact for well-formed input.
17
+ *
18
+ * The parser returns **positional metadata** (`startLine`, `endLine`) so
19
+ * callers can splice the table back into the surrounding document by
20
+ * range instead of hand-rolled line counters. Mutation operations return
21
+ * a fresh line array rather than mutating in place, matching the rest of
22
+ * the file-patching code in this codebase.
23
+ *
24
+ * Fallback tolerance: rows with more cells than the header get their
25
+ * overflow merged into the last column (using a literal `|` separator
26
+ * between the merged values). This is for the rare case where a FireForge
27
+ * writer emits an unescaped pipe — the parser still produces a consistent
28
+ * cell count rather than desynchronising the table. On re-serialize the
29
+ * merged cell's literal pipes are escaped, so a malformed input becomes
30
+ * a well-formed output after one round trip.
31
+ */
32
+ /**
33
+ * A parsed Markdown pipe table.
34
+ */
35
+ export interface MarkdownTable {
36
+ /** Column headers, trimmed. Cells are in document order. */
37
+ headers: string[];
38
+ /**
39
+ * Data rows. Each row is an array with exactly `headers.length` entries,
40
+ * trimmed. Rows shorter than the header are right-padded with `''`;
41
+ * longer rows have trailing cells merged into the last column so the
42
+ * round-trip is faithful.
43
+ */
44
+ rows: string[][];
45
+ /** 0-indexed line number of the header row in the source lines array. */
46
+ startLine: number;
47
+ /**
48
+ * 0-indexed line number *after* the final data row. `lines.slice(
49
+ * startLine, endLine)` yields exactly the table's lines.
50
+ */
51
+ endLine: number;
52
+ }
53
+ /**
54
+ * Locates the next Markdown table in `lines` starting at or after
55
+ * `fromLine`, skipping fenced code blocks. Returns `null` when no table
56
+ * is found.
57
+ *
58
+ * @param lines - The full document split by newline
59
+ * @param fromLine - Line index to start scanning from (inclusive)
60
+ */
61
+ export declare function findNextTable(lines: string[], fromLine: number): MarkdownTable | null;
62
+ /**
63
+ * Returns the first table whose header contains **all** of the provided
64
+ * column names (case-sensitive, order-insensitive). Useful when several
65
+ * pipe tables share a document and you want to select "the one with a
66
+ * Mode column".
67
+ */
68
+ export declare function findTableByColumns(lines: string[], requiredColumns: string[]): MarkdownTable | null;
69
+ /**
70
+ * Returns the first table that appears **after** a heading matching the
71
+ * given regex. The heading regex is applied to raw lines (no trimming)
72
+ * so callers that want leading whitespace flexibility should include
73
+ * `^\s*` or `^#+\s*` as appropriate.
74
+ */
75
+ export declare function findTableAfterHeading(lines: string[], headingPattern: RegExp): MarkdownTable | null;
76
+ /**
77
+ * Serialises a single row using the same `| a | b | c |` layout the rest
78
+ * of the token docs use. The leading and trailing pipes are always emitted
79
+ * so that concatenated rows line up consistently. Cell values containing
80
+ * literal `|` or `\` are escaped as `\|` and `\\` respectively so the
81
+ * output survives a round-trip through {@link findNextTable}.
82
+ */
83
+ export declare function serializeRow(cells: string[]): string;
84
+ /**
85
+ * Inserts a new row into `table.rows` at the given zero-based index. A
86
+ * negative index counts from the end; `Infinity` appends. Mutates the
87
+ * table object so callers using {@link rewriteTableRows} see the update.
88
+ */
89
+ export declare function insertRow(table: MarkdownTable, cells: string[], index: number): void;
90
+ /**
91
+ * Produces a new `lines` array with the given table region replaced by a
92
+ * freshly-serialized version of the table's current headers + rows. The
93
+ * separator row is regenerated from the current header count so edits
94
+ * that change the column count stay consistent.
95
+ */
96
+ export declare function rewriteTableRows(lines: string[], table: MarkdownTable): string[];
97
+ /**
98
+ * Updates a specific cell in the first row whose `keyColumn` cell matches
99
+ * `keyValue`. Returns `true` when a row was updated.
100
+ *
101
+ * Used by token-manager to bump the mode-count table without relying on
102
+ * brittle regex patterns that assume specific whitespace.
103
+ */
104
+ export declare function updateCellByKey(table: MarkdownTable, keyColumn: string, keyValue: string, targetColumn: string, newValue: string): boolean;
@@ -0,0 +1,266 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Minimal Markdown pipe-table parser/writer used by token-manager.
4
+ *
5
+ * This is **not** a general Markdown parser. It understands exactly the
6
+ * slice of GitHub-flavoured Markdown that FireForge emits into token docs:
7
+ *
8
+ * - Leading `|` per row, trailing `|` optional.
9
+ * - A separator row of `|---|---|...|` immediately after the header.
10
+ * - Fenced code blocks (``` / ~~~) are recognised and skipped so that
11
+ * literal `|` lines inside code samples are never mistaken for rows.
12
+ * - A table ends on the first non-pipe line (blank line, heading, prose).
13
+ * - Literal `|` and `\` inside a cell are expressed as `\|` and `\\`
14
+ * respectively. The parser treats any other backslash as a literal
15
+ * so prose-style backslashes in cell values (e.g. a Windows path)
16
+ * still round-trip. Unescaping happens at split time; serialization
17
+ * re-applies escapes so round-trips are exact for well-formed input.
18
+ *
19
+ * The parser returns **positional metadata** (`startLine`, `endLine`) so
20
+ * callers can splice the table back into the surrounding document by
21
+ * range instead of hand-rolled line counters. Mutation operations return
22
+ * a fresh line array rather than mutating in place, matching the rest of
23
+ * the file-patching code in this codebase.
24
+ *
25
+ * Fallback tolerance: rows with more cells than the header get their
26
+ * overflow merged into the last column (using a literal `|` separator
27
+ * between the merged values). This is for the rare case where a FireForge
28
+ * writer emits an unescaped pipe — the parser still produces a consistent
29
+ * cell count rather than desynchronising the table. On re-serialize the
30
+ * merged cell's literal pipes are escaped, so a malformed input becomes
31
+ * a well-formed output after one round trip.
32
+ */
33
+ /**
34
+ * Matches the table separator row: optional leading pipe, one or more
35
+ * dash-only cells (with optional `:` alignment hints) separated by pipes,
36
+ * optional trailing pipe, allowing whitespace around each cell.
37
+ */
38
+ const SEPARATOR_ROW = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
39
+ /**
40
+ * Returns `true` if the given line looks like a table row (starts with
41
+ * `|`, ignoring leading whitespace). Does not validate cell contents —
42
+ * that is the caller's job.
43
+ */
44
+ function isPipeRow(line) {
45
+ return /^\s*\|/.test(line);
46
+ }
47
+ /**
48
+ * Splits a pipe-delimited row into trimmed cell strings.
49
+ *
50
+ * Tolerates both `| a | b |` and `| a | b` (no trailing pipe). Leading
51
+ * empty cells (from a leading `|`) are dropped. A trailing empty cell
52
+ * (from a trailing `|`) is also dropped so that rows with and without
53
+ * the trailing pipe produce the same cell count.
54
+ *
55
+ * Escape handling: `\|` is consumed as a literal `|` inside a cell, and
56
+ * `\\` as a literal `\`. Any other backslash stays literal so prose-style
57
+ * uses of `\` survive untouched. Splitting and unescaping happen in the
58
+ * same pass so the returned cells are ready to hand back to callers.
59
+ */
60
+ function splitRow(row) {
61
+ const trimmed = row.trim();
62
+ const cells = [];
63
+ let current = '';
64
+ for (let i = 0; i < trimmed.length; i++) {
65
+ const ch = trimmed[i];
66
+ if (ch === '\\' && i + 1 < trimmed.length) {
67
+ const next = trimmed[i + 1];
68
+ if (next === '|' || next === '\\') {
69
+ current += next;
70
+ i++;
71
+ continue;
72
+ }
73
+ }
74
+ if (ch === '|') {
75
+ cells.push(current);
76
+ current = '';
77
+ continue;
78
+ }
79
+ current += ch ?? '';
80
+ }
81
+ cells.push(current);
82
+ // Leading `|` gives an empty first segment; drop it.
83
+ if (cells.length > 0 && cells[0] === '') {
84
+ cells.shift();
85
+ }
86
+ // Trailing `|` gives an empty last segment; drop it.
87
+ if (cells.length > 0 && cells[cells.length - 1] === '') {
88
+ cells.pop();
89
+ }
90
+ return cells.map((cell) => cell.trim());
91
+ }
92
+ /**
93
+ * Escapes a cell value so `serializeRow`'s pipe delimiters can be told
94
+ * apart from literal `|` inside a cell on re-parse. `\` is escaped first
95
+ * (to `\\`) so that the subsequent `|` → `\|` substitution cannot produce
96
+ * an already-escaped sequence by accident — i.e. a cell containing
97
+ * literal `\` followed by literal `|` round-trips as `\\\|`, which
98
+ * unescape-on-split reads back as `\` + `|`, not as `\|`.
99
+ */
100
+ function escapeCell(cell) {
101
+ return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
102
+ }
103
+ /**
104
+ * Fits a row of cells against a target column count. Extra cells are
105
+ * merged into the last column (so a stray `|` in a cell's value does not
106
+ * desynchronise the table), and missing cells are padded with `''`.
107
+ */
108
+ function fitRowToColumns(cells, columnCount) {
109
+ if (columnCount <= 0)
110
+ return [];
111
+ if (cells.length === columnCount) {
112
+ return cells;
113
+ }
114
+ if (cells.length < columnCount) {
115
+ return [...cells, ...Array(columnCount - cells.length).fill('')];
116
+ }
117
+ // cells.length > columnCount — merge overflow into the last column.
118
+ const kept = cells.slice(0, columnCount - 1);
119
+ const tail = cells.slice(columnCount - 1).join(' | ');
120
+ return [...kept, tail];
121
+ }
122
+ /**
123
+ * Locates the next Markdown table in `lines` starting at or after
124
+ * `fromLine`, skipping fenced code blocks. Returns `null` when no table
125
+ * is found.
126
+ *
127
+ * @param lines - The full document split by newline
128
+ * @param fromLine - Line index to start scanning from (inclusive)
129
+ */
130
+ export function findNextTable(lines, fromLine) {
131
+ let inFence = false;
132
+ let fenceMarker = null;
133
+ for (let i = fromLine; i < lines.length; i++) {
134
+ const line = lines[i] ?? '';
135
+ const fenceMatch = /^\s*(```|~~~)/.exec(line);
136
+ if (fenceMatch) {
137
+ if (!inFence) {
138
+ inFence = true;
139
+ fenceMarker = fenceMatch[1] ?? '```';
140
+ }
141
+ else if (fenceMarker && line.trim().startsWith(fenceMarker)) {
142
+ inFence = false;
143
+ fenceMarker = null;
144
+ }
145
+ continue;
146
+ }
147
+ if (inFence)
148
+ continue;
149
+ if (!isPipeRow(line))
150
+ continue;
151
+ const separatorLine = lines[i + 1];
152
+ if (separatorLine === undefined || !SEPARATOR_ROW.test(separatorLine))
153
+ continue;
154
+ const headers = splitRow(line);
155
+ if (headers.length === 0)
156
+ continue;
157
+ const rows = [];
158
+ let endLine = i + 2;
159
+ for (let j = i + 2; j < lines.length; j++) {
160
+ const rowLine = lines[j] ?? '';
161
+ if (!isPipeRow(rowLine))
162
+ break;
163
+ // Another separator line would signal a malformed table — bail.
164
+ if (SEPARATOR_ROW.test(rowLine))
165
+ break;
166
+ rows.push(fitRowToColumns(splitRow(rowLine), headers.length));
167
+ endLine = j + 1;
168
+ }
169
+ return { headers, rows, startLine: i, endLine };
170
+ }
171
+ return null;
172
+ }
173
+ /**
174
+ * Returns the first table whose header contains **all** of the provided
175
+ * column names (case-sensitive, order-insensitive). Useful when several
176
+ * pipe tables share a document and you want to select "the one with a
177
+ * Mode column".
178
+ */
179
+ export function findTableByColumns(lines, requiredColumns) {
180
+ let cursor = 0;
181
+ for (;;) {
182
+ const table = findNextTable(lines, cursor);
183
+ if (!table)
184
+ return null;
185
+ const headerSet = new Set(table.headers);
186
+ const matches = requiredColumns.every((column) => headerSet.has(column));
187
+ if (matches)
188
+ return table;
189
+ cursor = table.endLine;
190
+ }
191
+ }
192
+ /**
193
+ * Returns the first table that appears **after** a heading matching the
194
+ * given regex. The heading regex is applied to raw lines (no trimming)
195
+ * so callers that want leading whitespace flexibility should include
196
+ * `^\s*` or `^#+\s*` as appropriate.
197
+ */
198
+ export function findTableAfterHeading(lines, headingPattern) {
199
+ for (let i = 0; i < lines.length; i++) {
200
+ if (headingPattern.test(lines[i] ?? '')) {
201
+ return findNextTable(lines, i + 1);
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+ /**
207
+ * Serialises a single row using the same `| a | b | c |` layout the rest
208
+ * of the token docs use. The leading and trailing pipes are always emitted
209
+ * so that concatenated rows line up consistently. Cell values containing
210
+ * literal `|` or `\` are escaped as `\|` and `\\` respectively so the
211
+ * output survives a round-trip through {@link findNextTable}.
212
+ */
213
+ export function serializeRow(cells) {
214
+ if (cells.length === 0)
215
+ return '|';
216
+ return `| ${cells.map(escapeCell).join(' | ')} |`;
217
+ }
218
+ /**
219
+ * Inserts a new row into `table.rows` at the given zero-based index. A
220
+ * negative index counts from the end; `Infinity` appends. Mutates the
221
+ * table object so callers using {@link rewriteTableRows} see the update.
222
+ */
223
+ export function insertRow(table, cells, index) {
224
+ const fitted = fitRowToColumns(cells, table.headers.length);
225
+ const target = Math.max(0, Math.min(index, table.rows.length));
226
+ table.rows.splice(target, 0, fitted);
227
+ }
228
+ /**
229
+ * Produces a new `lines` array with the given table region replaced by a
230
+ * freshly-serialized version of the table's current headers + rows. The
231
+ * separator row is regenerated from the current header count so edits
232
+ * that change the column count stay consistent.
233
+ */
234
+ export function rewriteTableRows(lines, table) {
235
+ const headerLine = serializeRow(table.headers);
236
+ const separatorLine = serializeRow(table.headers.map(() => '---'));
237
+ const rowLines = table.rows.map((row) => serializeRow(row));
238
+ return [
239
+ ...lines.slice(0, table.startLine),
240
+ headerLine,
241
+ separatorLine,
242
+ ...rowLines,
243
+ ...lines.slice(table.endLine),
244
+ ];
245
+ }
246
+ /**
247
+ * Updates a specific cell in the first row whose `keyColumn` cell matches
248
+ * `keyValue`. Returns `true` when a row was updated.
249
+ *
250
+ * Used by token-manager to bump the mode-count table without relying on
251
+ * brittle regex patterns that assume specific whitespace.
252
+ */
253
+ export function updateCellByKey(table, keyColumn, keyValue, targetColumn, newValue) {
254
+ const keyIndex = table.headers.indexOf(keyColumn);
255
+ const targetIndex = table.headers.indexOf(targetColumn);
256
+ if (keyIndex === -1 || targetIndex === -1)
257
+ return false;
258
+ for (const row of table.rows) {
259
+ if (row[keyIndex] === keyValue) {
260
+ row[targetIndex] = newValue;
261
+ return true;
262
+ }
263
+ }
264
+ return false;
265
+ }
266
+ //# sourceMappingURL=markdown-table.js.map
@@ -0,0 +1,53 @@
1
+ import type { PatchMetadata } from '../types/commands/index.js';
2
+ /**
3
+ * A row in the flat path → owning-patch ownership table.
4
+ */
5
+ export interface OwnershipRow {
6
+ path: string;
7
+ owners: string[];
8
+ conflict: boolean;
9
+ conflictReason: 'files-affected' | 'duplicate-create' | null;
10
+ unmanaged: boolean;
11
+ }
12
+ interface StatusFile {
13
+ status: string;
14
+ file: string;
15
+ }
16
+ /**
17
+ * Builds the flat ownership table from the manifest, worktree status, and
18
+ * the duplicate-new-file-creation map produced by cross-patch lint.
19
+ *
20
+ * Populated from three sources:
21
+ *
22
+ * 1. Every path in every patch's `filesAffected` — so managed paths
23
+ * show up even when they are not currently modified on disk.
24
+ * 2. Any worktree status entries not claimed by any patch (flagged as
25
+ * `unmanaged`).
26
+ * 3. Paths created by more than one patch in `new file mode`,
27
+ * surfaced as ownership conflicts with
28
+ * `conflictReason = 'duplicate-create'`. This is the alignment
29
+ * fix between `status --ownership` and `fireforge verify`: a
30
+ * queue that `verify` rejects for duplicate `/dev/null → b/foo.js`
31
+ * creations was previously reported as clean here because the
32
+ * check only walked `filesAffected`, not the patch bodies.
33
+ *
34
+ * A path claimed by more than one patch (either via `filesAffected` or
35
+ * as a duplicate creation) is flagged as `conflict` with the origin
36
+ * recorded in `conflictReason` so the output can disambiguate.
37
+ *
38
+ * @param manifestPatches - Manifest rows, each with its `filesAffected`
39
+ * @param worktreeFiles - Raw worktree status entries; untracked and
40
+ * modified both acceptable
41
+ * @param newFileCreatorsByPath - Map produced by
42
+ * {@link import('../core/patch-lint.js').collectNewFileCreatorsByPath};
43
+ * paths with a `.length > 1` owner list become duplicate-create conflicts
44
+ */
45
+ export declare function buildOwnershipTable(manifestPatches: PatchMetadata[], worktreeFiles: StatusFile[], newFileCreatorsByPath: Map<string, string[]>): OwnershipRow[];
46
+ /**
47
+ * Renders the ownership table as a GitHub-flavored Markdown pipe table.
48
+ * Using markdown-table's own serializer would require a seed document to
49
+ * graft onto, which is overkill for ad-hoc status output; the rendering
50
+ * here is trivial enough to inline.
51
+ */
52
+ export declare function renderOwnershipTable(rows: OwnershipRow[]): void;
53
+ export {};
@@ -0,0 +1,144 @@
1
+ import { info } from '../utils/logger.js';
2
+ /**
3
+ * Builds the flat ownership table from the manifest, worktree status, and
4
+ * the duplicate-new-file-creation map produced by cross-patch lint.
5
+ *
6
+ * Populated from three sources:
7
+ *
8
+ * 1. Every path in every patch's `filesAffected` — so managed paths
9
+ * show up even when they are not currently modified on disk.
10
+ * 2. Any worktree status entries not claimed by any patch (flagged as
11
+ * `unmanaged`).
12
+ * 3. Paths created by more than one patch in `new file mode`,
13
+ * surfaced as ownership conflicts with
14
+ * `conflictReason = 'duplicate-create'`. This is the alignment
15
+ * fix between `status --ownership` and `fireforge verify`: a
16
+ * queue that `verify` rejects for duplicate `/dev/null → b/foo.js`
17
+ * creations was previously reported as clean here because the
18
+ * check only walked `filesAffected`, not the patch bodies.
19
+ *
20
+ * A path claimed by more than one patch (either via `filesAffected` or
21
+ * as a duplicate creation) is flagged as `conflict` with the origin
22
+ * recorded in `conflictReason` so the output can disambiguate.
23
+ *
24
+ * @param manifestPatches - Manifest rows, each with its `filesAffected`
25
+ * @param worktreeFiles - Raw worktree status entries; untracked and
26
+ * modified both acceptable
27
+ * @param newFileCreatorsByPath - Map produced by
28
+ * {@link import('../core/patch-lint.js').collectNewFileCreatorsByPath};
29
+ * paths with a `.length > 1` owner list become duplicate-create conflicts
30
+ */
31
+ export function buildOwnershipTable(manifestPatches, worktreeFiles, newFileCreatorsByPath) {
32
+ const ownersByPath = new Map();
33
+ for (const patch of manifestPatches) {
34
+ for (const file of patch.filesAffected) {
35
+ const existing = ownersByPath.get(file) ?? [];
36
+ existing.push(patch.filename);
37
+ ownersByPath.set(file, existing);
38
+ }
39
+ }
40
+ // Merge duplicate-new-file-creation findings. The structured helper
41
+ // returns all new-file paths, so we filter to the ones with more
42
+ // than one creator. Paths are added to the table even when no patch
43
+ // listed them in `filesAffected`, because the two-patch duplicate
44
+ // creation is exactly the shape that manifests as an ownership
45
+ // conflict without showing up in filesAffected (e.g. drift caused
46
+ // by manual patches.json editing).
47
+ const duplicateCreateByPath = new Map();
48
+ for (const [path, creators] of newFileCreatorsByPath) {
49
+ if (creators.length <= 1)
50
+ continue;
51
+ duplicateCreateByPath.set(path, creators);
52
+ if (!ownersByPath.has(path)) {
53
+ ownersByPath.set(path, [...creators]);
54
+ }
55
+ }
56
+ const worktreeSet = new Set(worktreeFiles.map((f) => f.file));
57
+ const unmanagedOnly = [];
58
+ for (const path of worktreeSet) {
59
+ if (!ownersByPath.has(path)) {
60
+ unmanagedOnly.push(path);
61
+ }
62
+ }
63
+ const rows = [];
64
+ for (const [path, owners] of ownersByPath) {
65
+ const duplicateOwners = duplicateCreateByPath.get(path);
66
+ const isFilesAffectedConflict = owners.length > 1;
67
+ const isDuplicateCreateConflict = duplicateOwners !== undefined;
68
+ // Prefer the filesAffected reason when both apply — it's the older
69
+ // source of drift and the operator will usually want to fix the
70
+ // manifest rows even when the bodies also duplicate-create.
71
+ const conflictReason = isFilesAffectedConflict
72
+ ? 'files-affected'
73
+ : isDuplicateCreateConflict
74
+ ? 'duplicate-create'
75
+ : null;
76
+ // If the duplicate-create finding introduced new patches not in
77
+ // filesAffected, merge them into owners so the rendered cell lists
78
+ // everyone responsible for the conflict, matching what `verify`
79
+ // prints.
80
+ const mergedOwners = duplicateOwners
81
+ ? Array.from(new Set([...owners, ...duplicateOwners])).sort((a, b) => a.localeCompare(b))
82
+ : owners;
83
+ rows.push({
84
+ path,
85
+ owners: mergedOwners,
86
+ conflict: isFilesAffectedConflict || isDuplicateCreateConflict,
87
+ conflictReason,
88
+ unmanaged: false,
89
+ });
90
+ }
91
+ for (const path of unmanagedOnly) {
92
+ rows.push({
93
+ path,
94
+ owners: [],
95
+ conflict: false,
96
+ conflictReason: null,
97
+ unmanaged: true,
98
+ });
99
+ }
100
+ rows.sort((a, b) => a.path.localeCompare(b.path));
101
+ return rows;
102
+ }
103
+ /**
104
+ * Human-readable label for the conflict column. Distinguishes
105
+ * `files-affected` drift (two manifest rows claiming the same path) from
106
+ * `duplicate-create` drift (two patches both hitting `/dev/null → b/path`
107
+ * in their bodies) so the operator can tell at a glance which fix
108
+ * applies — the former wants `re-export --files`, the latter wants
109
+ * `patch delete`.
110
+ */
111
+ function renderConflictCell(row) {
112
+ if (!row.conflict)
113
+ return row.unmanaged ? 'unmanaged' : '';
114
+ if (row.conflictReason === 'duplicate-create')
115
+ return 'CONFLICT (dup-create)';
116
+ return 'CONFLICT';
117
+ }
118
+ /**
119
+ * Renders the ownership table as a GitHub-flavored Markdown pipe table.
120
+ * Using markdown-table's own serializer would require a seed document to
121
+ * graft onto, which is overkill for ad-hoc status output; the rendering
122
+ * here is trivial enough to inline.
123
+ */
124
+ export function renderOwnershipTable(rows) {
125
+ if (rows.length === 0) {
126
+ info('No tracked or modified files.');
127
+ return;
128
+ }
129
+ const pathHeader = 'path';
130
+ const ownerHeader = 'owning patch';
131
+ const conflictHeader = 'conflict';
132
+ const pathWidth = Math.max(pathHeader.length, ...rows.map((r) => r.path.length));
133
+ const ownerWidth = Math.max(ownerHeader.length, ...rows.map((r) => (r.unmanaged ? 1 : r.owners.join(', ').length)));
134
+ const conflictWidth = Math.max(conflictHeader.length, ...rows.map((r) => renderConflictCell(r).length), 8);
135
+ const pad = (text, width) => text + ' '.repeat(width - text.length);
136
+ info(`| ${pad(pathHeader, pathWidth)} | ${pad(ownerHeader, ownerWidth)} | ${pad(conflictHeader, conflictWidth)} |`);
137
+ info(`| ${'-'.repeat(pathWidth)} | ${'-'.repeat(ownerWidth)} | ${'-'.repeat(conflictWidth)} |`);
138
+ for (const row of rows) {
139
+ const ownerCell = row.unmanaged ? '-' : row.owners.join(', ');
140
+ const conflictCell = renderConflictCell(row);
141
+ info(`| ${pad(row.path, pathWidth)} | ${pad(ownerCell, ownerWidth)} | ${pad(conflictCell, conflictWidth)} |`);
142
+ }
143
+ }
144
+ //# sourceMappingURL=ownership-table.js.map