@fro.bot/wiki-write-core 0.0.0-stage → 0.1.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 (42) hide show
  1. package/LICENSE.md +20 -0
  2. package/README.md +81 -2
  3. package/dist/branch-safety.d.ts +40 -0
  4. package/dist/branch-safety.js +31 -0
  5. package/dist/code-regions.d.ts +24 -0
  6. package/dist/code-regions.js +134 -0
  7. package/dist/correction-text.d.ts +2 -0
  8. package/dist/correction-text.js +4 -0
  9. package/dist/corrections-survival.d.ts +35 -0
  10. package/dist/corrections-survival.js +160 -0
  11. package/dist/corrections.d.ts +111 -0
  12. package/dist/corrections.js +328 -0
  13. package/dist/data-branch-bootstrap.d.ts +30 -0
  14. package/dist/data-branch-bootstrap.js +155 -0
  15. package/dist/frontmatter.d.ts +9 -0
  16. package/dist/frontmatter.js +29 -0
  17. package/dist/gate-contract.d.ts +2 -0
  18. package/dist/gate-contract.js +4 -0
  19. package/dist/gate-contract.json +1 -0
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.js +10 -0
  22. package/dist/markdown-links.d.ts +12 -0
  23. package/dist/markdown-links.js +62 -0
  24. package/dist/private-leak-adapter.d.ts +49 -0
  25. package/dist/private-leak-adapter.js +186 -0
  26. package/dist/private-leak.d.ts +30 -0
  27. package/dist/private-leak.js +141 -0
  28. package/dist/rendering-policy.d.ts +19 -0
  29. package/dist/rendering-policy.js +96 -0
  30. package/dist/repo-page-move.d.ts +87 -0
  31. package/dist/repo-page-move.js +389 -0
  32. package/dist/schemas.d.ts +141 -0
  33. package/dist/schemas.js +255 -0
  34. package/dist/wiki-ingest.d.ts +169 -0
  35. package/dist/wiki-ingest.js +1177 -0
  36. package/dist/wiki-lint.d.ts +120 -0
  37. package/dist/wiki-lint.js +400 -0
  38. package/dist/wiki-slug.d.ts +37 -0
  39. package/dist/wiki-slug.js +101 -0
  40. package/dist/wiki-utils.d.ts +81 -0
  41. package/dist/wiki-utils.js +208 -0
  42. package/package.json +88 -4
package/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Marcus R. Brown <<git@mrbro.dev>>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # `@fro.bot/wiki-write-core`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared validation and commit primitives for Fro Bot wiki writes: snapshot lint, wiki ingest, privacy-core seams, frontmatter helpers, and rendering-policy validation.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @fro.bot/wiki-write-core
9
+ # or: npm install @fro.bot/wiki-write-core
10
+ # or: bun add @fro.bot/wiki-write-core
11
+ ```
12
+
13
+ ## Runtime requirements
14
+
15
+ - Node `>=24`.
16
+ - **ESM-only.** The exports declare `import` and `types` conditions, with no `require` condition and no CommonJS build. CommonJS consumers fail at package resolution by design.
17
+
18
+ The package ships unbundled ESM JavaScript with declaration files from the committed `dist/` directory. No install-time build step is required. CI rebuilds `dist/` in a temporary directory and fails when the committed output differs from `src/`.
19
+
20
+ ## Fro Bot defaults
21
+
22
+ The package targets the Fro Bot control plane and carries its coordinates as defaults. Each is overridable through the call's parameters.
23
+
24
+ - `owner` defaults to `fro-bot` (`DEFAULT_OWNER`), and `repo` to `.github`.
25
+ - The canonical write target is the `fro-bot/.github@data` tuple: `branch` defaults to `data`.
26
+ - Tracked-repository metadata is read from `metadata/repos.yaml`.
27
+
28
+ When a call receives no Octokit client (`params.octokit`), it reads `GITHUB_TOKEN` from the environment and fails with a remediation message if the variable is unset.
29
+
30
+ The build also emits `dist/gate-contract.json` with `version` from `GATE_CONTRACT_VERSION` and `sourceTreeHash` from the same computed source hash embedded in `gate-contract.js`. Downstream writers can fetch this marker through the repository contents API; the same distribution drift check verifies the committed marker. `version` is the gate criterion: a mismatch means a deliberate contract break and refuses writes. `sourceTreeHash` is diagnostic only: it moves with ordinary source changes and reports drift in the pinned copy, but must never block writes.
31
+
32
+ The package exports `GATE_CONTRACT_VERSION` and `GATE_SOURCE_TREE_HASH` from its root entrypoint. The build embeds a deterministic hash covering every non-test TypeScript file under `src/`, the resolved `tsconfig.build.json` configuration including inherited settings, and canonicalized `exports` and `files` values from `package.json`. Object-key order and `files` array order do not affect the hash. The hash does not cover tests, this README, other manifest fields such as dependencies or version, or runtime dependencies. Because it hashes the _resolved_ compiler configuration, a TypeScript upgrade that changes an inherited default or the `--showConfig` serialization moves the hash with no semantic change to the shipped code; a hash that shifts across a compiler bump is expected and does not by itself indicate gate drift.
33
+
34
+ `GATE_SOURCE_TREE_HASH` is the automatic drift detector: compare it when you need to know whether the shipped gate inputs changed. `GATE_CONTRACT_VERSION` is a deliberate, human-set marker for an intentional consumer-visible contract break; it is not expected to change for every source edit. Neither value proves that dependencies or the runtime behave identically. Consumers should compare the source hash for ordinary gate-input drift and the contract version when deciding whether an intentional contract migration is required.
35
+
36
+ `gate-contract.json` is intentionally not exposed through the package `exports` map. Consumers must fetch the control plane's current marker through the contents API while loading their pinned package copy locally; resolving the bundled marker would defeat that comparison.
37
+
38
+ ## Finding kinds
39
+
40
+ `WikiLintFindingKind` includes `correction-eroded` for a blocking active-correction failure and `correction-needs-reconfirmation` for an advisory lifecycle or formatting-only change. Both retain the existing `{kind, path, message, target?}` runtime shape and fingerprint derivation. Consumers with exhaustive matches must handle both kinds.
41
+
42
+ ## Releasing
43
+
44
+ A merge to `main` that changes `packages/wiki-write-core/` releases the package through `.github/workflows/release-wiki-write-core.yaml`. Versions are the `wiki-write-core-v<version>` tags (for example `wiki-write-core-v0.1.1`), and each tag has a GitHub release.
45
+
46
+ Only commits that touch `packages/wiki-write-core/` are analyzed. The commit type decides the release:
47
+
48
+ | Commit | Release |
49
+ | ----------------------------------------------------------------------------------------------- | ------- |
50
+ | Breaking change (`!` or a `BREAKING CHANGE:` footer) | minor |
51
+ | `feat` | minor |
52
+ | `fix`, `build`, `docs(readme)` | patch |
53
+ | `build(dev)`, other `docs`, `chore`, `ci`, `test`, `refactor`, `skip`, non-conventional commits | none |
54
+
55
+ - A scoped suppression wins over a breaking marker: `build(dev)!:` releases nothing.
56
+ - While the package is 0.x it never releases a major version; a breaking change is a minor bump.
57
+ - A commit containing `[skip release]` (or `[release skip]`) is ignored by the release analysis.
58
+
59
+ ### Publishing
60
+
61
+ The `publish` job uses npm trusted publishing (OIDC) from `release-wiki-write-core.yaml` in the `release` environment, and publishes with provenance. There is no npm token. It publishes the committed `dist/`, which the `build` job proves equals a fresh build.
62
+
63
+ Verify a release:
64
+
65
+ - `npm view @fro.bot/wiki-write-core dist-tags.latest` returns the released version.
66
+ - `npm view @fro.bot/wiki-write-core@<version> --json` lists the provenance attestation under `dist.attestations`.
67
+ - The version page on npmjs.com shows the provenance badge, linking to `fro-bot/.github` and `release-wiki-write-core.yaml`.
68
+
69
+ ### Failure recovery
70
+
71
+ Start from the registry and tag state, not from the run's conclusion: a green run can release nothing.
72
+
73
+ | State | Recovery |
74
+ | --- | --- |
75
+ | No tag, no npm version | Fix the cause, then run the workflow with `workflow_dispatch` on `main` |
76
+ | Green run, package commits since the last tag, no release (`main` moved past the checkout, or the push run was skipped by `[skip ci]`) | `workflow_dispatch` on `main` |
77
+ | Tag present, version neither live nor staged on npm | Delete the tag and its GitHub release if one exists, then `workflow_dispatch` on `main`. Do this before merging any other package change |
78
+ | Tag and GitHub release present, npm skipped the publish (for example `private` in the manifest) | Fix the manifest, delete the tag and GitHub release, then `workflow_dispatch` on `main` |
79
+ | Version staged on npm instead of live | Promote it on npm, then set the trusted publisher to allow `npm publish` |
80
+ | OIDC publish fails (expired, misconfigured or stage-only trusted publisher) | Delete and recreate the trusted publisher: owner `fro-bot`, repo `.github`, workflow `release-wiki-write-core.yaml`, environment `release`, allowed action `npm publish`. Then resolve the tag as in the third row |
81
+ | Tag and version live, no GitHub release | Create the GitHub release from the tag. Never delete the tag |
82
+ | Bad version published | Deprecate it and ship a fix release. Never delete the tag |
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The one branch-safety rule shared by every writer that commits to `fro-bot/.github`.
3
+ *
4
+ * - Never `main`: writers target `data`, and `main` only receives it through a promotion PR.
5
+ * - Never a protected branch, except the canonical `fro-bot/.github@data`: its ruleset reports it as
6
+ * protected while bypassing the App by actor, so the App writer is the one intended to write there.
7
+ *
8
+ * Each writer reports a refusal in its own error type, so this function takes a `refuse` callback
9
+ * instead of throwing: the rule lives here once, and the error shape stays with the caller.
10
+ */
11
+ /** Why a branch was refused. */
12
+ export type BranchRefusal = 'main' | 'protected';
13
+ /** The one call the rule needs. The real Octokit satisfies it structurally. */
14
+ export interface BranchProtectionClient {
15
+ rest: {
16
+ repos: {
17
+ getBranch: (params: {
18
+ owner: string;
19
+ repo: string;
20
+ branch: string;
21
+ }) => Promise<{
22
+ data: {
23
+ protected?: boolean;
24
+ protection?: {
25
+ enabled?: boolean;
26
+ } | null;
27
+ };
28
+ }>;
29
+ };
30
+ };
31
+ }
32
+ /**
33
+ * Resolve if `owner/repo@branch` may be written, otherwise call `refuse` (which must throw).
34
+ *
35
+ * `main` is refused without a network call. Otherwise GitHub is asked, and both the top-level
36
+ * `protected` flag and the nested `protection.enabled` are checked: the REST API surfaces branch
37
+ * protection through both, and older clients that read only the latter miss repos configured
38
+ * through rulesets.
39
+ */
40
+ export declare function assertBranchWritable(client: BranchProtectionClient, owner: string, repo: string, branch: string, refuse: (reason: BranchRefusal) => never): Promise<void>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The one branch-safety rule shared by every writer that commits to `fro-bot/.github`.
3
+ *
4
+ * - Never `main`: writers target `data`, and `main` only receives it through a promotion PR.
5
+ * - Never a protected branch, except the canonical `fro-bot/.github@data`: its ruleset reports it as
6
+ * protected while bypassing the App by actor, so the App writer is the one intended to write there.
7
+ *
8
+ * Each writer reports a refusal in its own error type, so this function takes a `refuse` callback
9
+ * instead of throwing: the rule lives here once, and the error shape stays with the caller.
10
+ */
11
+ const CANONICAL_OWNER = 'fro-bot';
12
+ const CANONICAL_REPO = '.github';
13
+ const CANONICAL_BRANCH = 'data';
14
+ const MAIN_BRANCH = 'main';
15
+ /**
16
+ * Resolve if `owner/repo@branch` may be written, otherwise call `refuse` (which must throw).
17
+ *
18
+ * `main` is refused without a network call. Otherwise GitHub is asked, and both the top-level
19
+ * `protected` flag and the nested `protection.enabled` are checked: the REST API surfaces branch
20
+ * protection through both, and older clients that read only the latter miss repos configured
21
+ * through rulesets.
22
+ */
23
+ export async function assertBranchWritable(client, owner, repo, branch, refuse) {
24
+ if (branch === MAIN_BRANCH)
25
+ return refuse('main');
26
+ const response = await client.rest.repos.getBranch({ owner, repo, branch });
27
+ const isProtected = response.data.protected === true || response.data.protection?.enabled === true;
28
+ const isCanonical = owner === CANONICAL_OWNER && repo === CANONICAL_REPO && branch === CANONICAL_BRANCH;
29
+ if (isProtected && !isCanonical)
30
+ return refuse('protected');
31
+ }
@@ -0,0 +1,24 @@
1
+ /** A half-open `[start, end)` range of offsets in a string that holds literal code, not markdown. */
2
+ export interface CodeRegion {
3
+ readonly start: number;
4
+ readonly end: number;
5
+ }
6
+ /**
7
+ * Locate fenced code blocks and inline code spans in markdown, as sorted, non-overlapping regions.
8
+ * Link extractors use it so link-shaped text inside code stays literal.
9
+ *
10
+ * - A fence opens at a line with up to three spaces of indentation, then three or more backticks
11
+ * or tildes. A backtick fence's info string may not contain a backtick; a line that breaks that
12
+ * rule is not a fence and is parsed for inline spans instead. A tilde fence's info string may
13
+ * contain anything.
14
+ * - A fence closes at a line of the same character, at least as long as the opener and followed
15
+ * only by spaces or tabs. An unclosed fence runs to the end of the content. The fence lines
16
+ * themselves are part of the region.
17
+ * - Outside a fence, a run of N backticks opens an inline span that closes at the next run of
18
+ * exactly N backticks on the same line (CommonMark, minus spans that cross a line break). A run
19
+ * with no matching closer is literal text. A backslash outside a span escapes the next
20
+ * character, so an escaped backtick never opens a span.
21
+ */
22
+ export declare function findCodeRegions(content: string): CodeRegion[];
23
+ /** The region that contains `offset`, if any. `regions` must come from {@link findCodeRegions}. */
24
+ export declare function findRegionAt(regions: readonly CodeRegion[], offset: number): CodeRegion | undefined;
@@ -0,0 +1,134 @@
1
+ // Up to three spaces of indentation, then a run of three or more backticks or tildes and an info string.
2
+ const FENCE_OPENER = /^ {0,3}(`{3,}|~{3,})/u;
3
+ // A closing fence carries nothing but spaces or tabs after the marker.
4
+ const FENCE_CLOSER = /^ {0,3}(`{3,}|~{3,})[ \t]*$/u;
5
+ /**
6
+ * Locate fenced code blocks and inline code spans in markdown, as sorted, non-overlapping regions.
7
+ * Link extractors use it so link-shaped text inside code stays literal.
8
+ *
9
+ * - A fence opens at a line with up to three spaces of indentation, then three or more backticks
10
+ * or tildes. A backtick fence's info string may not contain a backtick; a line that breaks that
11
+ * rule is not a fence and is parsed for inline spans instead. A tilde fence's info string may
12
+ * contain anything.
13
+ * - A fence closes at a line of the same character, at least as long as the opener and followed
14
+ * only by spaces or tabs. An unclosed fence runs to the end of the content. The fence lines
15
+ * themselves are part of the region.
16
+ * - Outside a fence, a run of N backticks opens an inline span that closes at the next run of
17
+ * exactly N backticks on the same line (CommonMark, minus spans that cross a line break). A run
18
+ * with no matching closer is literal text. A backslash outside a span escapes the next
19
+ * character, so an escaped backtick never opens a span.
20
+ */
21
+ export function findCodeRegions(content) {
22
+ const regions = [];
23
+ let fence;
24
+ let lineStart = 0;
25
+ while (lineStart <= content.length) {
26
+ const newline = content.indexOf('\n', lineStart);
27
+ const lineEnd = newline === -1 ? content.length : newline;
28
+ const line = content.slice(lineStart, lineEnd);
29
+ // A CRLF line ending is a line ending, not part of the line.
30
+ const text = line.endsWith('\r') ? line.slice(0, -1) : line;
31
+ if (fence === undefined) {
32
+ const opener = parseFenceOpener(text);
33
+ if (opener === undefined) {
34
+ for (const span of findInlineSpans(line)) {
35
+ regions.push({ start: lineStart + span.start, end: lineStart + span.end });
36
+ }
37
+ }
38
+ else {
39
+ fence = { ...opener, start: lineStart };
40
+ }
41
+ }
42
+ else if (closesFence(text, fence)) {
43
+ regions.push({ start: fence.start, end: lineEnd });
44
+ fence = undefined;
45
+ }
46
+ lineStart = lineEnd + 1;
47
+ }
48
+ if (fence !== undefined) {
49
+ regions.push({ start: fence.start, end: content.length });
50
+ }
51
+ return regions;
52
+ }
53
+ function parseFenceOpener(text) {
54
+ const match = FENCE_OPENER.exec(text);
55
+ const marker = match?.[1];
56
+ if (match === null || marker === undefined) {
57
+ return undefined;
58
+ }
59
+ const character = marker.charAt(0);
60
+ const info = text.slice(match[0].length);
61
+ return character === '`' && info.includes('`') ? undefined : { character, length: marker.length };
62
+ }
63
+ function closesFence(text, fence) {
64
+ const marker = FENCE_CLOSER.exec(text)?.[1];
65
+ return marker !== undefined && marker.startsWith(fence.character) && marker.length >= fence.length;
66
+ }
67
+ /** The region that contains `offset`, if any. `regions` must come from {@link findCodeRegions}. */
68
+ export function findRegionAt(regions, offset) {
69
+ let low = 0;
70
+ let high = regions.length - 1;
71
+ while (low <= high) {
72
+ const middle = (low + high) >> 1;
73
+ const region = regions[middle];
74
+ if (region === undefined) {
75
+ return undefined;
76
+ }
77
+ if (offset < region.start) {
78
+ high = middle - 1;
79
+ }
80
+ else if (offset >= region.end) {
81
+ low = middle + 1;
82
+ }
83
+ else {
84
+ return region;
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+ function findInlineSpans(line) {
90
+ const spans = [];
91
+ let index = 0;
92
+ while (index < line.length) {
93
+ const character = line[index];
94
+ if (character === '\\') {
95
+ index += 2;
96
+ continue;
97
+ }
98
+ if (character !== '`') {
99
+ index += 1;
100
+ continue;
101
+ }
102
+ const openEnd = skipBackticks(line, index);
103
+ const runLength = openEnd - index;
104
+ let searchFrom = openEnd;
105
+ let closeEnd = -1;
106
+ while (searchFrom < line.length) {
107
+ const closeStart = line.indexOf('`', searchFrom);
108
+ if (closeStart === -1) {
109
+ break;
110
+ }
111
+ const candidateEnd = skipBackticks(line, closeStart);
112
+ if (candidateEnd - closeStart === runLength) {
113
+ closeEnd = candidateEnd;
114
+ break;
115
+ }
116
+ searchFrom = candidateEnd;
117
+ }
118
+ if (closeEnd === -1) {
119
+ index = openEnd;
120
+ }
121
+ else {
122
+ spans.push({ start: index, end: closeEnd });
123
+ index = closeEnd;
124
+ }
125
+ }
126
+ return spans;
127
+ }
128
+ function skipBackticks(line, from) {
129
+ let index = from;
130
+ while (line[index] === '`') {
131
+ index += 1;
132
+ }
133
+ return index;
134
+ }
@@ -0,0 +1,2 @@
1
+ /** Normalize the exact survival text used by both storage validation and ingest checks. */
2
+ export declare function normalizeCorrectionText(value: string): string;
@@ -0,0 +1,4 @@
1
+ /** Normalize the exact survival text used by both storage validation and ingest checks. */
2
+ export function normalizeCorrectionText(value) {
3
+ return value.trim().replaceAll(/\s+/gu, ' ');
4
+ }
@@ -0,0 +1,35 @@
1
+ import { type CorrectionsFile, type CorrectionSurvivalResult } from './corrections.js';
2
+ /**
3
+ * Verify marked spans mechanically after ingest regeneration.
4
+ *
5
+ * Matching trims the span and collapses every whitespace run to one space, then
6
+ * performs an exact substring search in prose only. Markdown inline links are
7
+ * excluded from that exact search; wiki links remain in it because their target
8
+ * text is page prose for this purpose. Fenced code, indented code, and blockquotes
9
+ * are excluded because quoted material is not evidence that the correction survived
10
+ * in the page's actual prose.
11
+ *
12
+ * If exact prose matching fails, a second conservative comparison replaces
13
+ * Markdown links with their visible text, removes Markdown emphasis/code markers,
14
+ * converts punctuation to whitespace, collapses whitespace, and lowercases text.
15
+ * A match under that rule is formatting-only drift and emits an advisory
16
+ * `correction-needs-reconfirmation`; any other miss is erosion and blocks ingest.
17
+ */
18
+ export declare function verifyCorrectionSurvival(files: Record<string, string>, corrections: CorrectionsFile | undefined, fallbackFiles?: Record<string, string>): CorrectionSurvivalResult;
19
+ /**
20
+ * Substitutes each link's visible label (markdown or wiki, labeled or bare) before the generic
21
+ * punctuation strip below. A single combined alternation, scanned once left to right: a
22
+ * two-pass split (markdown pattern, then wiki pattern) lets the first pass's substitution text
23
+ * form a NEW `[[...|...]]`-shaped string the second pass then matches, re-interpreting already-
24
+ * substituted output as if it were original content (see corrections-survival.test.ts's
25
+ * `[[[]()a|b]]` counterexample). Exported only for the exhaustive differential test against
26
+ * the pre-refactor reference implementation.
27
+ */
28
+ export declare function normalizeFormattingText(value: string): string;
29
+ /**
30
+ * Masks markdown inline links `[label](url)` to spaces so exact prose matching ignores link
31
+ * targets; wiki links `[[...]]` are left untouched (module docstring). A regex port was proven
32
+ * non-equivalent by exhaustive differential testing (corrections-survival.test.ts), so this
33
+ * scanner stays; its directived lines are deterministic hangs under mutation, not timing noise.
34
+ */
35
+ export declare function maskMarkdownLinks(content: string): string;
@@ -0,0 +1,160 @@
1
+ import { normalizeCorrectionText } from "./correction-text.js";
2
+ import { assertCorrectionsFile, getCorrectionLifecycle, } from "./corrections.js";
3
+ import { maskNonProseContent } from "./rendering-policy.js";
4
+ import { collectWikiPages } from "./wiki-utils.js";
5
+ /**
6
+ * Verify marked spans mechanically after ingest regeneration.
7
+ *
8
+ * Matching trims the span and collapses every whitespace run to one space, then
9
+ * performs an exact substring search in prose only. Markdown inline links are
10
+ * excluded from that exact search; wiki links remain in it because their target
11
+ * text is page prose for this purpose. Fenced code, indented code, and blockquotes
12
+ * are excluded because quoted material is not evidence that the correction survived
13
+ * in the page's actual prose.
14
+ *
15
+ * If exact prose matching fails, a second conservative comparison replaces
16
+ * Markdown links with their visible text, removes Markdown emphasis/code markers,
17
+ * converts punctuation to whitespace, collapses whitespace, and lowercases text.
18
+ * A match under that rule is formatting-only drift and emits an advisory
19
+ * `correction-needs-reconfirmation`; any other miss is erosion and blocks ingest.
20
+ */
21
+ export function verifyCorrectionSurvival(files, corrections, fallbackFiles = {}) {
22
+ if (corrections === undefined)
23
+ return { ok: true, deterministicFindings: [], advisoryFindings: [] };
24
+ assertCorrectionsFile(corrections);
25
+ const pages = collectWikiPages(files);
26
+ const fallbackPages = collectWikiPages(fallbackFiles);
27
+ // Keyed by `unknown`: correction.page_node_id is a validated non-empty string
28
+ // (corrections.ts parseLooseCorrectionRecord), so unkeyable pages are never looked up.
29
+ const pagesByNodeId = new Map();
30
+ const fallbackPagesByNodeId = new Map();
31
+ for (const page of pages)
32
+ pagesByNodeId.set(page.frontmatter.node_id, page);
33
+ for (const page of fallbackPages)
34
+ fallbackPagesByNodeId.set(page.frontmatter.node_id, page);
35
+ const deterministicFindings = [];
36
+ const advisoryFindings = [];
37
+ for (const correction of corrections.corrections) {
38
+ const state = getCorrectionLifecycle(correction);
39
+ if (state === 'superseded' || state === 'retired')
40
+ continue;
41
+ const page = pagesByNodeId.get(correction.page_node_id);
42
+ const fallbackPage = fallbackPagesByNodeId.get(correction.page_node_id);
43
+ const path = page?.path ?? fallbackPage?.path ?? 'knowledge/corrections.yaml';
44
+ if (state === 'needs-reconfirmation') {
45
+ advisoryFindings.push({
46
+ kind: 'correction-needs-reconfirmation',
47
+ path,
48
+ target: correction.id,
49
+ recovery: { lifecycle: 'needs-reconfirmation', action: 'reconfirm-correction' },
50
+ message: `Correction ${correction.id} needs operator reconfirmation before it is enforced.`,
51
+ });
52
+ continue;
53
+ }
54
+ // normalizedSpan is never '' (assertCorrectionSpan, corrections.ts:185); page === undefined
55
+ // forces proseBody '', and '' can never include a non-empty span, so that disjunct is dead.
56
+ const normalizedSpan = normalizeCorrectionText(correction.span.text);
57
+ const proseBody = page === undefined ? '' : maskNonProseContent(page.body);
58
+ const normalizedBody = normalizeCorrectionText(maskMarkdownLinks(proseBody));
59
+ if (!normalizedBody.includes(normalizedSpan)) {
60
+ const formattingSpan = normalizeFormattingText(correction.span.text);
61
+ const formattingBody = normalizeFormattingText(proseBody);
62
+ if (formattingSpan !== '' && formattingBody.includes(formattingSpan)) {
63
+ advisoryFindings.push({
64
+ kind: 'correction-needs-reconfirmation',
65
+ path,
66
+ target: correction.id,
67
+ recovery: { lifecycle: 'needs-reconfirmation', action: 'reconfirm-correction' },
68
+ message: `Correction ${correction.id} appears preserved with formatting-only changes and needs operator reconfirmation.`,
69
+ });
70
+ continue;
71
+ }
72
+ deterministicFindings.push({
73
+ kind: 'correction-eroded',
74
+ path,
75
+ target: correction.id,
76
+ recovery: { lifecycle: 'active', action: 'restore-span' },
77
+ message: `Active correction ${correction.id} was not found in the regenerated page.`,
78
+ });
79
+ }
80
+ }
81
+ return {
82
+ ok: deterministicFindings.length === 0,
83
+ deterministicFindings,
84
+ advisoryFindings,
85
+ };
86
+ }
87
+ /**
88
+ * Substitutes each link's visible label (markdown or wiki, labeled or bare) before the generic
89
+ * punctuation strip below. A single combined alternation, scanned once left to right: a
90
+ * two-pass split (markdown pattern, then wiki pattern) lets the first pass's substitution text
91
+ * form a NEW `[[...|...]]`-shaped string the second pass then matches, re-interpreting already-
92
+ * substituted output as if it were original content (see corrections-survival.test.ts's
93
+ * `[[[]()a|b]]` counterexample). Exported only for the exhaustive differential test against
94
+ * the pre-refactor reference implementation.
95
+ */
96
+ export function normalizeFormattingText(value) {
97
+ const markdownLinkPattern = /!?(?:\[([^\]]*)\]\([^)]*\)|\[\[([^\]|]+)(?:\|([^\]]+))?\]\])/gu;
98
+ return (value
99
+ .normalize('NFKC')
100
+ .replaceAll(markdownLinkPattern, renderVisibleLinkText)
101
+ // `\s+` below absorbs any run this leaves; a `+` here would be unobservable.
102
+ .replaceAll(/[^\p{L}\p{N}]/gu, ' ')
103
+ .trim()
104
+ .replaceAll(/\s+/gu, ' ')
105
+ .toLowerCase());
106
+ }
107
+ function renderVisibleLinkText(_match, markdownText, wikiTarget, wikiLabel) {
108
+ // markdownLinkPattern's outer alternation guarantees exactly one branch matched whenever this
109
+ // callback runs: the markdown branch always captures markdownText (possibly '', never
110
+ // undefined), and the wiki branch always captures wikiTarget (required, 1+ chars). A final
111
+ // '' fallback covering "neither captured" is unreachable -- asserted, not defaulted.
112
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see comment above
113
+ return (markdownText ?? wikiLabel ?? wikiTarget);
114
+ }
115
+ /**
116
+ * Masks markdown inline links `[label](url)` to spaces so exact prose matching ignores link
117
+ * targets; wiki links `[[...]]` are left untouched (module docstring). A regex port was proven
118
+ * non-equivalent by exhaustive differential testing (corrections-survival.test.ts), so this
119
+ * scanner stays; its directived lines are deterministic hangs under mutation, not timing noise.
120
+ */
121
+ export function maskMarkdownLinks(content) {
122
+ const masked = content.split('');
123
+ let open = -1;
124
+ let index = 0;
125
+ // Stryker disable next-line BlockStatement,EqualityOperator: BlockStatement mutation destroys mandatory loop progress (deterministic hang, not a timing artifact); EqualityOperator's >= variant hangs on empty content (0 >= 0 is true, index only grows, so it never becomes false again) and its <= variant only extends the loop to content[content.length], which is always undefined and has no side effect on open/masked/depth
126
+ while (index < content.length) {
127
+ if (content[index] === '[')
128
+ open = index;
129
+ if (content[index] === ']' && content[index + 1] === '(' && open !== -1) {
130
+ let close = index + 2;
131
+ let depth = 1;
132
+ // Split from the original `close < content.length && depth > 0` combined condition so
133
+ // each half's mutants land on their own line; the `depth <= 0` check below is bounded by
134
+ // this while's own `close < content.length`, so its mutants are not a hang, just wrong
135
+ // output, and are killed by the differential test like any other behavior change.
136
+ // Stryker disable next-line BlockStatement,EqualityOperator: BlockStatement mutation destroys mandatory loop progress (deterministic hang, not a timing artifact); EqualityOperator's >= variant hangs when close starts at or past content.length (a link's `](` at the very end of the string) for the same reason as the outer loop above, and its <= variant only extends the loop to content[content.length], which is always undefined and has no side effect on open/masked/depth
137
+ while (close < content.length) {
138
+ if (depth <= 0)
139
+ break;
140
+ if (content[close] === '(')
141
+ depth += 1;
142
+ else if (content[close] === ')')
143
+ depth -= 1;
144
+ // Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
145
+ close += 1;
146
+ }
147
+ if (depth === 0) {
148
+ // Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
149
+ for (let maskIndex = open; maskIndex < close; maskIndex += 1)
150
+ masked[maskIndex] = ' ';
151
+ index = close;
152
+ open = -1;
153
+ continue;
154
+ }
155
+ }
156
+ // Stryker disable next-line AssignmentOperator: mutation destroys mandatory loop progress; deterministic hang, not a timing artifact
157
+ index += 1;
158
+ }
159
+ return masked.join('');
160
+ }