@fro.bot/wiki-write-core 0.1.2 → 0.1.4

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.
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { dirname, join, normalize } from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { findCodeRegions, findRegionAt } from "./code-regions.js";
5
- const MARKDOWN_LINK_PATTERN = /\[[^\]\n]+\]\(\s*(<[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^)\n]*\)))?\s*\)/gu;
5
+ import { createInlineLinkFinder } from "./inline-links.js";
6
6
  /** Resolve live relative markdown links and report whether their targets exist. */
7
7
  export function resolveMarkdownLinks(content, containingFile, options = {}) {
8
8
  const links = [];
@@ -11,12 +11,14 @@ export function resolveMarkdownLinks(content, containingFile, options = {}) {
11
11
  for (const [lineIndex, line] of content.split('\n').entries()) {
12
12
  const currentLineOffset = lineOffset;
13
13
  lineOffset += line.length + 1;
14
- // The shared global pattern is stateful; every line restarts it and the loop runs it to exhaustion.
15
- MARKDOWN_LINK_PATTERN.lastIndex = 0;
16
- for (let match = MARKDOWN_LINK_PATTERN.exec(line); match !== null; match = MARKDOWN_LINK_PATTERN.exec(line)) {
17
- const target = match[1];
14
+ // Each line gets its own finder; the loop asks it for the next link at or after `from`.
15
+ const findLink = createInlineLinkFinder(line);
16
+ let from = 0;
17
+ for (let match = findLink(from); match !== undefined; match = findLink(from)) {
18
+ const { target } = match;
18
19
  const matchIndex = match.index;
19
- if (target === undefined || (matchIndex > 0 && line[matchIndex - 1] === '!')) {
20
+ from = matchIndex + match.length;
21
+ if (matchIndex > 0 && line[matchIndex - 1] === '!') {
20
22
  continue;
21
23
  }
22
24
  // Code binds tighter than links, so a match that touches a fence or span is literal text.
@@ -24,14 +26,14 @@ export function resolveMarkdownLinks(content, containingFile, options = {}) {
24
26
  // after an unclosed bracket in code. Resume after the region the match opened in, or just
25
27
  // past its first character when it only closes inside one.
26
28
  const matchStart = currentLineOffset + matchIndex;
27
- const matchLast = matchStart + match[0].length - 1;
29
+ const matchLast = matchStart + match.length - 1;
28
30
  const openingRegion = findRegionAt(regions, matchStart);
29
31
  if (openingRegion !== undefined) {
30
- MARKDOWN_LINK_PATTERN.lastIndex = Math.max(openingRegion.end - currentLineOffset, matchIndex + 1);
32
+ from = Math.max(openingRegion.end - currentLineOffset, matchIndex + 1);
31
33
  continue;
32
34
  }
33
35
  if (findRegionAt(regions, matchLast) !== undefined) {
34
- MARKDOWN_LINK_PATTERN.lastIndex = matchIndex + 1;
36
+ from = matchIndex + 1;
35
37
  continue;
36
38
  }
37
39
  const normalizedTarget = target.startsWith('<') && target.endsWith('>') ? target.slice(1, -1) : target;
@@ -1,11 +1,25 @@
1
- const UNSAFE_TAG_PATTERN = /<\/?\s*(?:script|iframe|object|embed|applet|base|form|input|button|textarea|select|option|meta|link|style|template|svg|math)\b[^>]*>/iu;
2
- const EVENT_HANDLER_PATTERN = /\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/iu;
3
- const UNSAFE_URI_PATTERN = /\s+(?:href|src|srcset|action|formaction|xlink:href|poster|background|cite)\s*=\s*(?:"\s*|')?(?:javascript|vbscript|data):/iu;
1
+ // A tag is unsafe when its name opens it and a `>` follows anywhere after the name. The pattern stops at the
2
+ // name and `hasUnsafeTag` looks for the `>`: a `[^>]*>` tail rescans the rest of the text from every opening
3
+ // when there is no `>`, which is quadratic in the length of the text.
4
+ const UNSAFE_TAG_OPENING_PATTERN = /<\/?\s*(?:script|iframe|object|embed|applet|base|form|input|button|textarea|select|option|meta|link|style|template|svg|math)\b/iu;
5
+ // Both attribute patterns start at the first whitespace character of a run: a match that starts inside a run also
6
+ // starts at the run's beginning, and without the lookbehind a long run of whitespace is rescanned from every
7
+ // position in it.
8
+ const EVENT_HANDLER_PATTERN = /(?<!\s)\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/iu;
9
+ const UNSAFE_URI_PATTERN = /(?<!\s)\s+(?:href|src|srcset|action|formaction|xlink:href|poster|background|cite)\s*=\s*(?:"\s*|')?(?:javascript|vbscript|data):/iu;
10
+ /**
11
+ * True when `text` holds an unsafe tag: an opening `<name` or `</name` from the list, then a `>` after the name.
12
+ * The first opening ends before any later one, so if it has no `>` after it none does.
13
+ */
14
+ function hasUnsafeTag(text) {
15
+ const opening = UNSAFE_TAG_OPENING_PATTERN.exec(text);
16
+ return opening !== null && text.includes('>', opening.index + opening[0].length);
17
+ }
4
18
  /** Return save-side findings for HTML that the render policy will strip. */
5
19
  export function validateRenderingPolicy(params) {
6
20
  const findings = [];
7
21
  const renderableMarkdown = maskCodeContent(params.content);
8
- if (UNSAFE_TAG_PATTERN.test(renderableMarkdown) ||
22
+ if (hasUnsafeTag(renderableMarkdown) ||
9
23
  EVENT_HANDLER_PATTERN.test(renderableMarkdown) ||
10
24
  UNSAFE_URI_PATTERN.test(renderableMarkdown)) {
11
25
  findings.push({
@@ -14,6 +14,7 @@
14
14
  * - `log.md` history and `knowledge/wiki/README.md` are never rewritten.
15
15
  */
16
16
  import { parseFrontmatterDocument, renderFrontmatterDocument } from "./frontmatter.js";
17
+ import { parseRelatedFlow } from "./line-scanners.js";
17
18
  import { appendLogEntry, rebuildWikiIndex, validateWikilinks, WikiIngestError } from "./wiki-ingest.js";
18
19
  import { buildPrivateNameTokens, computeRepoSlug } from "./wiki-slug.js";
19
20
  import { findWikilinkSpans } from "./wiki-utils.js";
@@ -22,7 +23,6 @@ const LOG_PATH = 'knowledge/log.md';
22
23
  const REPOS_DIR = 'knowledge/wiki/repos';
23
24
  const WIKI_PAGE_PATTERN = /^knowledge\/wiki\/(?:repos|topics|entities|comparisons)\/[^/]+\.md$/u;
24
25
  const RELATED_BLOCK_KEY = /^related:\s*(?:#.*)?$/u;
25
- const RELATED_FLOW = /^related:\s*\[(.*)\]\s*(?:#.*)?$/u;
26
26
  /** Apply a change set to a file map, returning a new map. */
27
27
  export function applyPageChanges(files, changes) {
28
28
  const next = { ...files };
@@ -301,9 +301,9 @@ function editRelatedText(content, oldSlug, newSlug) {
301
301
  if (frontmatter === undefined)
302
302
  return undefined;
303
303
  const lines = frontmatter.split('\n');
304
- const flowIndex = lines.findIndex(line => RELATED_FLOW.test(line));
304
+ const flowIndex = lines.findIndex(line => parseRelatedFlow(line) !== undefined);
305
305
  if (flowIndex !== -1) {
306
- const inner = RELATED_FLOW.exec(lines[flowIndex] ?? '')?.[1] ?? '';
306
+ const inner = parseRelatedFlow(lines[flowIndex] ?? '') ?? '';
307
307
  const items = inner.split(',').map(item => item.trim());
308
308
  const mapped = dedupe(items.map(item => (stripQuotes(item) === oldSlug ? swapQuoted(item, newSlug) : item)));
309
309
  lines[flowIndex] = `related: [${mapped.join(', ')}]`;
@@ -31,6 +31,16 @@ export interface BuildWikiIngestChangesParams {
31
31
  trackedMetadataAvailable?: boolean;
32
32
  /** System-owned correction state used for post-regeneration survival verification. */
33
33
  corrections?: CorrectionsFile;
34
+ /**
35
+ * Log entries an agent appended to its own `knowledge/log.md`, derived by the caller. Appended to
36
+ * the existing log, before this ingest's own summary entry.
37
+ */
38
+ agentLogEntries?: string;
39
+ /**
40
+ * Pages an agent deleted in a handoff, already removed from `existingFiles`. They join the result's
41
+ * `deletedPaths`, and a call with only deletions needs no page updates.
42
+ */
43
+ handoffDeletedPaths?: readonly string[];
34
44
  }
35
45
  export interface BuildWikiIngestChangesResult {
36
46
  files: Record<string, string>;
@@ -38,9 +48,9 @@ export interface BuildWikiIngestChangesResult {
38
48
  findings: readonly WikiLintFinding[];
39
49
  }
40
50
  export interface WikiIngestWarning {
41
- code: 'duplicate-repo-identity' | 'repos-metadata-unavailable';
51
+ code: 'duplicate-repo-identity' | 'repos-metadata-unavailable' | 'agent-log-entries-dropped';
42
52
  node_ids?: string[];
43
- reason?: 'read-failed' | 'parse-failed';
53
+ reason?: 'read-failed' | 'parse-failed' | 'not-append-only';
44
54
  slugs?: string[];
45
55
  }
46
56
  export interface TrackedRepoNodeIdsResult {
@@ -65,6 +75,21 @@ export interface CommitWikiChangesParams {
65
75
  * after that read are caught.
66
76
  */
67
77
  baseCommitSha?: string;
78
+ /**
79
+ * A second, per-path base for a write whose pages were edited by an agent job in an earlier job.
80
+ * `agentCommitSha` is the data commit that job synced; `paths` are the pages it changed or deleted
81
+ * (`knowledge/log.md` and `knowledge/index.md` are ignored here: the writer rebuilds both itself).
82
+ *
83
+ * Before any write, the commit must resolve and be an ancestor of the branch head, or the call fails
84
+ * with `HANDOFF_BASE_INVALID`. Every attempt then compares each path on the head it reads with the
85
+ * same path in that commit's tree, ahead of the `baseCommitSha` check, and fails with
86
+ * `HANDOFF_PAGE_CHANGED` if any differs: another writer edited or deleted the page since the agent
87
+ * read it.
88
+ */
89
+ handoff?: {
90
+ agentCommitSha: string;
91
+ paths: readonly string[];
92
+ };
68
93
  octokit?: OctokitClient;
69
94
  maxRetries?: number;
70
95
  /**
@@ -82,13 +107,15 @@ export interface RunWikiIngestCliDependencies {
82
107
  readonly readCorrections?: typeof readCorrections;
83
108
  readonly commitWikiChanges?: typeof commitWikiChanges;
84
109
  readonly getChangedWikiPaths?: typeof getChangedWikiPaths;
110
+ /** Client for reading the agent's base commit; defaults to one built from `GITHUB_TOKEN`. */
111
+ readonly octokit?: OctokitClient;
85
112
  }
86
113
  /**
87
114
  * Narrow Octokit client type derived from the real `@octokit/rest` SDK.
88
115
  * See commit-metadata.ts for the rationale behind deriving rather than handwriting.
89
116
  */
90
117
  export type OctokitClient = Octokit;
91
- export type WikiIngestErrorCode = 'INVALID_PAYLOAD' | 'INVALID_PAGE_PATH' | 'INVALID_FRONTMATTER' | 'INVALID_WIKILINK' | 'CORRECTION_ERODED' | 'INVALID_RETRIES' | 'PROTECTED_BRANCH' | 'MISSING_TOKEN' | 'OCTOKIT_LOAD_FAILED' | 'CONFLICT_EXHAUSTED';
118
+ export type WikiIngestErrorCode = 'INVALID_PAYLOAD' | 'INVALID_PAGE_PATH' | 'INVALID_FRONTMATTER' | 'INVALID_WIKILINK' | 'CORRECTION_ERODED' | 'INVALID_RETRIES' | 'PROTECTED_BRANCH' | 'MISSING_TOKEN' | 'OCTOKIT_LOAD_FAILED' | 'CONFLICT_EXHAUSTED' | 'HANDOFF_BASE_INVALID' | 'HANDOFF_PAGE_CHANGED';
92
119
  export declare class WikiIngestError extends Error {
93
120
  readonly code: WikiIngestErrorCode;
94
121
  readonly remediation: string;
@@ -100,6 +127,8 @@ export declare class WikiIngestError extends Error {
100
127
  findings?: readonly WikiLintFinding[];
101
128
  });
102
129
  }
130
+ /** The two wiki files every writer shares. Writers rebuild both from their own sync; a handoff never supplies them. */
131
+ export declare function isSharedWikiFile(path: string): boolean;
103
132
  export declare function buildWikiIngestChanges(params: BuildWikiIngestChangesParams): BuildWikiIngestChangesResult;
104
133
  /**
105
134
  * Commits `params.files` (and removes `params.deletedPaths`) as one commit on the data branch.
@@ -109,6 +138,9 @@ export declare function buildWikiIngestChanges(params: BuildWikiIngestChangesPar
109
138
  * when it is omitted), another writer changed it, so the write fails with `CONFLICT_EXHAUSTED` and
110
139
  * nothing is written. A ref race (`updateRef` 409, or 422 not-a-fast-forward) re-reads the head and
111
140
  * replays the same files on it, up to `maxRetries` attempts, under the same guard.
141
+ *
142
+ * With `handoff`, the handed-off paths are checked first on every attempt against the agent's data
143
+ * commit (`HANDOFF_PAGE_CHANGED`), so a page conflict is never reported as a ref race.
112
144
  */
113
145
  export declare function commitWikiChanges(params: CommitWikiChangesParams): Promise<CommitWikiChangesResult>;
114
146
  export declare function validateWikilinks(files: Record<string, string>): void;
@@ -182,6 +214,12 @@ export declare function parsePorcelainPaths(stdout: string): string[];
182
214
  * the four category directories.
183
215
  */
184
216
  export declare function countWikiPages(paths: string[]): number;
217
+ /**
218
+ * The log text an agent appended: what its `knowledge/log.md` adds after `knowledge/log.md` at the
219
+ * commit it started from. `undefined` when the agent changed anything before that point, or the base
220
+ * log is empty, so there is nothing safe to take. Both inputs are normalized first (LF, one final newline).
221
+ */
222
+ export declare function deriveAgentLogEntries(baseLog: string, handoffLog: string): string | undefined;
185
223
  export declare function runWikiIngestCli(dependencies?: RunWikiIngestCliDependencies): Promise<void>;
186
224
  type ReadUtf8File = (path: string, encoding: 'utf8') => Promise<string>;
187
225
  export declare function loadTrackedRepoNodeIds(readFileImpl?: ReadUtf8File): Promise<TrackedRepoNodeIdsResult>;