@untangle-code/context-artifacts 0.0.0-stage → 0.1.0

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 (109) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +247 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/adapters/asyncapi.d.ts +20 -0
  6. package/dist/adapters/asyncapi.d.ts.map +1 -0
  7. package/dist/adapters/asyncapi.js +95 -0
  8. package/dist/adapters/asyncapi.js.map +1 -0
  9. package/dist/adapters/docker.d.ts +43 -0
  10. package/dist/adapters/docker.d.ts.map +1 -0
  11. package/dist/adapters/docker.js +97 -0
  12. package/dist/adapters/docker.js.map +1 -0
  13. package/dist/adapters/kubernetes.d.ts +22 -0
  14. package/dist/adapters/kubernetes.d.ts.map +1 -0
  15. package/dist/adapters/kubernetes.js +56 -0
  16. package/dist/adapters/kubernetes.js.map +1 -0
  17. package/dist/adapters/markdown.d.ts +35 -0
  18. package/dist/adapters/markdown.d.ts.map +1 -0
  19. package/dist/adapters/markdown.js +36 -0
  20. package/dist/adapters/markdown.js.map +1 -0
  21. package/dist/adapters/openapi.d.ts +21 -0
  22. package/dist/adapters/openapi.d.ts.map +1 -0
  23. package/dist/adapters/openapi.js +101 -0
  24. package/dist/adapters/openapi.js.map +1 -0
  25. package/dist/adapters/protobuf.d.ts +34 -0
  26. package/dist/adapters/protobuf.d.ts.map +1 -0
  27. package/dist/adapters/protobuf.js +221 -0
  28. package/dist/adapters/protobuf.js.map +1 -0
  29. package/dist/adapters/shared.d.ts +65 -0
  30. package/dist/adapters/shared.d.ts.map +1 -0
  31. package/dist/adapters/shared.js +95 -0
  32. package/dist/adapters/shared.js.map +1 -0
  33. package/dist/adapters/sql.d.ts +104 -0
  34. package/dist/adapters/sql.d.ts.map +1 -0
  35. package/dist/adapters/sql.js +427 -0
  36. package/dist/adapters/sql.js.map +1 -0
  37. package/dist/adapters/terraform.d.ts +40 -0
  38. package/dist/adapters/terraform.d.ts.map +1 -0
  39. package/dist/adapters/terraform.js +186 -0
  40. package/dist/adapters/terraform.js.map +1 -0
  41. package/dist/adapters/text.d.ts +24 -0
  42. package/dist/adapters/text.d.ts.map +1 -0
  43. package/dist/adapters/text.js +31 -0
  44. package/dist/adapters/text.js.map +1 -0
  45. package/dist/config-validation.d.ts +18 -0
  46. package/dist/config-validation.d.ts.map +1 -0
  47. package/dist/config-validation.js +30 -0
  48. package/dist/config-validation.js.map +1 -0
  49. package/dist/coverage.d.ts +114 -0
  50. package/dist/coverage.d.ts.map +1 -0
  51. package/dist/coverage.js +0 -0
  52. package/dist/coverage.js.map +1 -0
  53. package/dist/ids.d.ts +12 -0
  54. package/dist/ids.d.ts.map +1 -0
  55. package/dist/ids.js +42 -0
  56. package/dist/ids.js.map +1 -0
  57. package/dist/index.d.ts +55 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +18 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/registry.d.ts +51 -0
  62. package/dist/registry.d.ts.map +1 -0
  63. package/dist/registry.js +86 -0
  64. package/dist/registry.js.map +1 -0
  65. package/dist/staleness.d.ts +44 -0
  66. package/dist/staleness.d.ts.map +1 -0
  67. package/dist/staleness.js +35 -0
  68. package/dist/staleness.js.map +1 -0
  69. package/dist/text/delimiter-balance.d.ts +95 -0
  70. package/dist/text/delimiter-balance.d.ts.map +1 -0
  71. package/dist/text/delimiter-balance.js +209 -0
  72. package/dist/text/delimiter-balance.js.map +1 -0
  73. package/dist/text/line-index.d.ts +61 -0
  74. package/dist/text/line-index.d.ts.map +1 -0
  75. package/dist/text/line-index.js +75 -0
  76. package/dist/text/line-index.js.map +1 -0
  77. package/dist/text/yaml-lite.d.ts +73 -0
  78. package/dist/text/yaml-lite.d.ts.map +1 -0
  79. package/dist/text/yaml-lite.js +486 -0
  80. package/dist/text/yaml-lite.js.map +1 -0
  81. package/package.json +61 -3
  82. package/src/adapters/__golden__/adr-0001-sections.json +68 -0
  83. package/src/adapters/__golden__/app-compose-services.json +24 -0
  84. package/src/adapters/__golden__/app-image-stages.json +24 -0
  85. package/src/adapters/__golden__/infra-main-sections.json +46 -0
  86. package/src/adapters/__golden__/orders-events-operations.json +24 -0
  87. package/src/adapters/__golden__/orders-proto-sections.json +57 -0
  88. package/src/adapters/__golden__/orders-schema-statements.json +68 -0
  89. package/src/adapters/__golden__/users-api-operations.json +46 -0
  90. package/src/adapters/__golden__/web-manifests-resources.json +24 -0
  91. package/src/adapters/asyncapi.ts +147 -0
  92. package/src/adapters/docker.ts +170 -0
  93. package/src/adapters/kubernetes.ts +103 -0
  94. package/src/adapters/markdown.ts +88 -0
  95. package/src/adapters/openapi.ts +156 -0
  96. package/src/adapters/protobuf.ts +286 -0
  97. package/src/adapters/shared.ts +151 -0
  98. package/src/adapters/sql.ts +555 -0
  99. package/src/adapters/terraform.ts +261 -0
  100. package/src/adapters/text.ts +72 -0
  101. package/src/config-validation.ts +41 -0
  102. package/src/coverage.ts +336 -0
  103. package/src/ids.ts +57 -0
  104. package/src/index.ts +105 -0
  105. package/src/registry.ts +155 -0
  106. package/src/staleness.ts +69 -0
  107. package/src/text/delimiter-balance.ts +295 -0
  108. package/src/text/line-index.ts +108 -0
  109. package/src/text/yaml-lite.ts +565 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Kubernetes YAML adapter (architecture.md §20: "je Dokument eine
3
+ * Ressource, apiVersion/kind/name erhalten").
4
+ *
5
+ * Each `---`-separated YAML document in the file becomes exactly one
6
+ * chunk, kind `"kubernetes_resource"`, spanning that document's full line
7
+ * range — Kubernetes manifests are not sectioned further, since a
8
+ * Deployment's `spec.template` is meaningless read apart from its
9
+ * `metadata`. A document whose YAML fails to parse still gets its full raw
10
+ * span turned into a chunk (kind `"kubernetes_resource_parse_error"`, title
11
+ * carries the reason) rather than aborting the whole file: one malformed
12
+ * manifest in a directory of many must not hide the rest (architecture.md
13
+ * §22.2's cross-repo-search principle, applied at document granularity).
14
+ */
15
+ import type {
16
+ ArtifactAdapter,
17
+ ArtifactChunk,
18
+ ArtifactDocument,
19
+ ArtifactInput,
20
+ ArtifactLink,
21
+ } from "@untangle-code/domain";
22
+
23
+ import { buildLineIndex } from "../text/line-index.js";
24
+ import {
25
+ mapGet,
26
+ parseYamlNode,
27
+ scalarString,
28
+ splitYamlDocuments,
29
+ YamlParseError,
30
+ } from "../text/yaml-lite.js";
31
+
32
+ import type { ChunkDraft, SkipLedgerReporter } from "./shared.js";
33
+ import { buildChunksReportingSkips, buildDocuments } from "./shared.js";
34
+
35
+ const YAML_EXTENSION = /\.ya?ml$/i;
36
+ const COMPOSE_FILENAME = /(^|\/)(docker-)?compose\.ya?ml$/i;
37
+
38
+ function kubernetesDrafts(content: string): ChunkDraft[] {
39
+ return splitYamlDocuments(content).map((span): ChunkDraft => {
40
+ try {
41
+ const root = parseYamlNode(span.text);
42
+ const apiVersion = scalarString(mapGet(root, "apiVersion"));
43
+ const kind = scalarString(mapGet(root, "kind"));
44
+ const name = scalarString(mapGet(mapGet(root, "metadata"), "name"));
45
+ const title =
46
+ [apiVersion, kind].filter((part) => part !== undefined).join("/") +
47
+ (name === undefined ? "" : ` ${name}`);
48
+ return {
49
+ kind: "kubernetes_resource",
50
+ startLine: span.startLine,
51
+ endLine: span.endLine,
52
+ ...(title.trim().length === 0 ? {} : { title: title.trim() }),
53
+ };
54
+ } catch (error) {
55
+ const reason = error instanceof YamlParseError ? error.message : "unknown parse error";
56
+ return {
57
+ kind: "kubernetes_resource_parse_error",
58
+ startLine: span.startLine,
59
+ endLine: span.endLine,
60
+ title: `parse error: ${reason}`,
61
+ };
62
+ }
63
+ });
64
+ }
65
+
66
+ export interface KubernetesAdapterOptions {
67
+ /** Overrides how a real skip-ledger finding surfaces from `chunk()` (default: a stderr warning) — see `coverage.ts`'s `SkipLedgerReporter`. */
68
+ onSkipLedgerGap?: SkipLedgerReporter;
69
+ }
70
+
71
+ export function createKubernetesAdapter(options: KubernetesAdapterOptions = {}): ArtifactAdapter {
72
+ return {
73
+ supports(type: string, path: string): boolean {
74
+ // "auto" detection only claims .yaml/.yml files that are not a
75
+ // Compose file (see docker.ts) — every other ambiguous case (a plain
76
+ // config .yaml, an OpenAPI/AsyncAPI file without a name hint) should
77
+ // be configured with an explicit `type`, since path alone cannot
78
+ // disambiguate them (spec.md §19.1's `supports` has no content
79
+ // access — see README "Entscheidungen").
80
+ return (
81
+ type === "kubernetes" ||
82
+ (type === "auto" && YAML_EXTENSION.test(path) && !COMPOSE_FILENAME.test(path))
83
+ );
84
+ },
85
+
86
+ async read(input: ArtifactInput): Promise<ArtifactDocument[]> {
87
+ return buildDocuments(input, "kubernetes");
88
+ },
89
+
90
+ chunk(document: ArtifactDocument): ArtifactChunk[] {
91
+ return buildChunksReportingSkips(
92
+ document,
93
+ buildLineIndex(document.content),
94
+ kubernetesDrafts(document.content),
95
+ options.onSkipLedgerGap,
96
+ );
97
+ },
98
+
99
+ links(): ArtifactLink[] {
100
+ return [];
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Markdown / ADR adapter (architecture.md §20, spec.md §19.2 "semantische
3
+ * Abschnitte statt fester Zeilenfenster").
4
+ *
5
+ * ADRs are not a separate `ContextArtifactType` (spec.md §19.1's type union
6
+ * has no "adr" literal — an Architecture Decision Record IS a Markdown
7
+ * document, conventionally with `## Status`/`## Context`/`## Decision`/
8
+ * `## Consequences` headings) — this one adapter's heading-based sectioning
9
+ * handles both.
10
+ *
11
+ * WP-110: the sectioning itself no longer lives here. It moved verbatim to
12
+ * `@untangle-code/domain`'s `outlineMarkdown()` so that the SAME partition
13
+ * also serves ordinary `.md` files through the parser-registry prose adapter
14
+ * and the prose metrics — before that move, a configured artifact got
15
+ * breadcrumbed sections while the identical file outside an artifact
16
+ * configuration got arbitrary line windows. Behaviour here is unchanged; the
17
+ * implementation is simply no longer duplicated.
18
+ *
19
+ * Sectioning (defined on `outlineMarkdown`): each ATX heading (`#` …
20
+ * `######`, ignored inside fenced code blocks) starts a new chunk that runs
21
+ * up to the START of the very next heading of ANY level (or EOF) — a flat
22
+ * partition of the file with no overlap. Each chunk's `title` carries the
23
+ * breadcrumb (ancestor headings joined by " › ") instead, so the hierarchy
24
+ * is still fully recoverable even though `content` stays the exact raw slice
25
+ * — the breadcrumb is never spliced into the preserved raw content
26
+ * (spec.md §19.2).
27
+ */
28
+ import type {
29
+ ArtifactAdapter,
30
+ ArtifactChunk,
31
+ ArtifactDocument,
32
+ ArtifactInput,
33
+ ArtifactLink,
34
+ } from "@untangle-code/domain";
35
+ import { outlineMarkdown } from "@untangle-code/domain";
36
+
37
+ import { buildLineIndex, splitLines } from "../text/line-index.js";
38
+
39
+ import type { ChunkDraft, SkipLedgerReporter } from "./shared.js";
40
+ import { buildChunksReportingSkips, buildDocuments } from "./shared.js";
41
+
42
+ const MARKDOWN_EXTENSION = /\.(md|markdown|mdx)$/i;
43
+
44
+ function markdownDrafts(lines: string[]): ChunkDraft[] {
45
+ return outlineMarkdown(lines).map((section) => {
46
+ const draft: ChunkDraft = {
47
+ kind: "markdown_section",
48
+ startLine: section.startLine,
49
+ endLine: section.endLine,
50
+ };
51
+ // Der Vorspann vor der ersten Ueberschrift hat keinen Breadcrumb; ein
52
+ // leerer `title` waere eine Behauptung ueber eine Ueberschrift, die es
53
+ // nicht gibt.
54
+ return section.breadcrumb === "" ? draft : { ...draft, title: section.breadcrumb };
55
+ });
56
+ }
57
+
58
+ export interface MarkdownAdapterOptions {
59
+ /** Overrides how a real skip-ledger finding surfaces from `chunk()` (default: a stderr warning) — see `coverage.ts`'s `SkipLedgerReporter`. */
60
+ onSkipLedgerGap?: SkipLedgerReporter;
61
+ }
62
+
63
+ export function createMarkdownAdapter(options: MarkdownAdapterOptions = {}): ArtifactAdapter {
64
+ return {
65
+ supports(type: string, path: string): boolean {
66
+ return type === "markdown" || (type === "auto" && MARKDOWN_EXTENSION.test(path));
67
+ },
68
+
69
+ async read(input: ArtifactInput): Promise<ArtifactDocument[]> {
70
+ return buildDocuments(input, "markdown");
71
+ },
72
+
73
+ chunk(document: ArtifactDocument): ArtifactChunk[] {
74
+ const { lines } = splitLines(document.content);
75
+ const index = buildLineIndex(document.content);
76
+ return buildChunksReportingSkips(
77
+ document,
78
+ index,
79
+ markdownDrafts(lines),
80
+ options.onSkipLedgerGap,
81
+ );
82
+ },
83
+
84
+ links(): ArtifactLink[] {
85
+ return [];
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * OpenAPI adapter (architecture.md §20: "je Operation ein Abschnitt,
3
+ * Operation-IDs erhalten").
4
+ *
5
+ * Parses the document with the package's YAML subset parser
6
+ * (`text/yaml-lite.ts`) and emits one chunk per `paths.<path>.<method>`
7
+ * operation, titled with the HTTP method, path and (when present)
8
+ * `operationId` — never dropped even when absent from the title, since
9
+ * `content` is always the exact raw YAML block, `operationId` field
10
+ * included. A malformed document degrades to a single whole-document
11
+ * `"openapi_parse_error"` chunk (architecture.md §22.2) rather than
12
+ * throwing — the raw content is still fully preserved in that chunk.
13
+ */
14
+ import type {
15
+ ArtifactAdapter,
16
+ ArtifactChunk,
17
+ ArtifactDocument,
18
+ ArtifactInput,
19
+ ArtifactLink,
20
+ } from "@untangle-code/domain";
21
+
22
+ import { buildLineIndex } from "../text/line-index.js";
23
+ import {
24
+ mapGet,
25
+ parseYamlNode,
26
+ scalarString,
27
+ YamlParseError,
28
+ type YamlNode,
29
+ } from "../text/yaml-lite.js";
30
+
31
+ import type { ChunkDraft, SkipLedgerReporter } from "./shared.js";
32
+ import {
33
+ buildChunks,
34
+ buildChunksReportingSkips,
35
+ buildDocuments,
36
+ wholeDocumentDraft,
37
+ } from "./shared.js";
38
+
39
+ const OPENAPI_NAME_HINT = /openapi/i;
40
+ const YAML_OR_JSON_EXTENSION = /\.(ya?ml|json)$/i;
41
+
42
+ const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const;
43
+
44
+ function openApiDrafts(root: YamlNode | null): ChunkDraft[] {
45
+ if (root === null || root.kind !== "map") return [];
46
+ const drafts: ChunkDraft[] = [];
47
+
48
+ const info = mapGet(root, "info");
49
+ if (info !== undefined) {
50
+ const title = scalarString(mapGet(info, "title"));
51
+ drafts.push({
52
+ kind: "openapi_info",
53
+ startLine: info.startLine,
54
+ endLine: info.endLine,
55
+ ...(title === undefined ? {} : { title: `info: ${title}` }),
56
+ });
57
+ }
58
+
59
+ const paths = mapGet(root, "paths");
60
+ if (paths !== undefined && paths.kind === "map") {
61
+ for (const pathEntry of paths.entries) {
62
+ if (pathEntry.value.kind !== "map") continue;
63
+ for (const methodEntry of pathEntry.value.entries) {
64
+ const method = methodEntry.key.toLowerCase();
65
+ if (!(HTTP_METHODS as readonly string[]).includes(method)) continue;
66
+ const operationId = scalarString(mapGet(methodEntry.value, "operationId"));
67
+ const title =
68
+ operationId === undefined
69
+ ? `${method.toUpperCase()} ${pathEntry.key}`
70
+ : `${method.toUpperCase()} ${pathEntry.key} (${operationId})`;
71
+ drafts.push({
72
+ kind: "openapi_operation",
73
+ startLine: methodEntry.value.startLine,
74
+ endLine: methodEntry.value.endLine,
75
+ title,
76
+ });
77
+ }
78
+ }
79
+ }
80
+ return drafts;
81
+ }
82
+
83
+ function operationIdOf(chunk: ArtifactChunk): string | undefined {
84
+ if (chunk.kind !== "openapi_operation" || chunk.title === undefined) return undefined;
85
+ const match = /\(([^()]+)\)$/.exec(chunk.title);
86
+ return match?.[1];
87
+ }
88
+
89
+ function draftsFor(document: ArtifactDocument): ChunkDraft[] {
90
+ try {
91
+ return openApiDrafts(parseYamlNode(document.content));
92
+ } catch (error) {
93
+ const reason = error instanceof YamlParseError ? error.message : "unknown parse error";
94
+ return [
95
+ wholeDocumentDraft(
96
+ "openapi_parse_error",
97
+ buildLineIndex(document.content),
98
+ `parse error: ${reason}`,
99
+ ),
100
+ ];
101
+ }
102
+ }
103
+
104
+ export interface OpenApiAdapterOptions {
105
+ /** Overrides how a real skip-ledger finding surfaces from `chunk()` (default: a stderr warning) — see `coverage.ts`'s `SkipLedgerReporter`. */
106
+ onSkipLedgerGap?: SkipLedgerReporter;
107
+ }
108
+
109
+ export function createOpenApiAdapter(options: OpenApiAdapterOptions = {}): ArtifactAdapter {
110
+ return {
111
+ supports(type: string, path: string): boolean {
112
+ return (
113
+ type === "openapi" ||
114
+ (type === "auto" && OPENAPI_NAME_HINT.test(path) && YAML_OR_JSON_EXTENSION.test(path))
115
+ );
116
+ },
117
+
118
+ async read(input: ArtifactInput): Promise<ArtifactDocument[]> {
119
+ return buildDocuments(input, "openapi");
120
+ },
121
+
122
+ chunk(document: ArtifactDocument): ArtifactChunk[] {
123
+ return buildChunksReportingSkips(
124
+ document,
125
+ buildLineIndex(document.content),
126
+ draftsFor(document),
127
+ options.onSkipLedgerGap,
128
+ );
129
+ },
130
+
131
+ /**
132
+ * Heuristic (architecture.md §20's canonical example): assumes the
133
+ * common REST-codegen convention that a controller method is named
134
+ * after the operation's `operationId`. No AST/symbol table is
135
+ * consulted here — `targetId` stays unset; a later graph-linking stage
136
+ * resolves `targetName` against real symbols, if any match.
137
+ */
138
+ links(document: ArtifactDocument): ArtifactLink[] {
139
+ const chunks = buildChunks(document, buildLineIndex(document.content), draftsFor(document));
140
+ const links: ArtifactLink[] = [];
141
+ for (const chunk of chunks) {
142
+ const operationId = operationIdOf(chunk);
143
+ if (operationId === undefined) continue;
144
+ links.push({
145
+ fromChunkId: chunk.chunkId,
146
+ targetKind: "symbol",
147
+ targetName: operationId,
148
+ heuristic: true,
149
+ confidence: 0.5,
150
+ note: `naming-convention guess: OpenAPI operationId "${operationId}" → a controller method of the same name`,
151
+ });
152
+ }
153
+ return links;
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Protobuf adapter (architecture.md §20: "Messages, Services, RPCs").
3
+ *
4
+ * Brace-depth line scanning (no full grammar needed): a top-level
5
+ * (depth-0) `message Foo {` or `service Foo {` opens a block that runs to
6
+ * its matching `}`. Nested `message`s inside another message are part of
7
+ * the parent's content (not chunked separately — a deliberate scope limit,
8
+ * see README "Entscheidungen"; their bytes are still fully present in the
9
+ * parent chunk's content, so nothing is silently dropped). Inside a
10
+ * `service` block, each `rpc Name(...) returns (...);` additionally gets
11
+ * its own finer-grained chunk — intentionally OVERLAPPING the enclosing
12
+ * service chunk's range, so a search hit on one RPC's name returns a tight
13
+ * chunk while a hit on shared service-level comments/options still returns
14
+ * full context.
15
+ *
16
+ * The brace counter is string/comment-aware (WP-81 review finding: a `{` or
17
+ * `}` inside a string default value, e.g. `[default = "{"]`, or inside a
18
+ * `//`/`/* *\/` comment, must never move the depth counter — a naive raw
19
+ * count desyncs PERMANENTLY on such a line, degrading "runs to EOF" and
20
+ * silently swallowing every following top-level block). `scanLine` below
21
+ * carries string/block-comment state across lines through ONE continuous
22
+ * scan (a single shared `ScanState`, not a fresh one per block) — depth is
23
+ * tracked exactly once per line, not once in this function and again in a
24
+ * separate look-ahead, so there is only one source of truth for "are we
25
+ * inside a string/comment right now" across the whole file.
26
+ */
27
+ import type {
28
+ ArtifactAdapter,
29
+ ArtifactChunk,
30
+ ArtifactDocument,
31
+ ArtifactInput,
32
+ ArtifactLink,
33
+ } from "@untangle-code/domain";
34
+
35
+ import type { DelimiterScanOptions } from "../text/delimiter-balance.js";
36
+ import { describeDelimiterImbalances, findDelimiterImbalances } from "../text/delimiter-balance.js";
37
+ import { buildLineIndex, splitLines } from "../text/line-index.js";
38
+
39
+ import type { ChunkDraft, ChunkPlausibilityCheck, SkipLedgerReporter } from "./shared.js";
40
+ import { buildChunks, buildChunksReportingSkips, buildDocuments } from "./shared.js";
41
+
42
+ const PROTO_EXTENSION = /\.proto$/i;
43
+ const MESSAGE_RE = /^\s*message\s+([\w.]+)\s*\{/;
44
+ const SERVICE_RE = /^\s*service\s+([\w.]+)\s*\{/;
45
+ const RPC_RE = /^\s*rpc\s+(\w+)\s*\(/;
46
+
47
+ /** Cross-line scanner state: which of a string literal / block comment (if any) the scan is currently inside. */
48
+ interface ScanState {
49
+ inSingle: boolean;
50
+ inDouble: boolean;
51
+ inBlockComment: boolean;
52
+ }
53
+
54
+ /**
55
+ * Net brace delta for one line, skipping `'...'`/`"..."` string literals
56
+ * (with `\`-escapes) and `//`/`/* *\/` comments, so a brace character
57
+ * inside either never affects depth. `state` is mutated in place for
58
+ * anything that spans past this line's end (an unterminated string or an
59
+ * open block comment) — the NEXT call for the following line continues
60
+ * from exactly that state, which is what makes the counter immune to
61
+ * per-line resets.
62
+ */
63
+ function scanLine(line: string, state: ScanState): number {
64
+ let delta = 0;
65
+ for (let i = 0; i < line.length; i += 1) {
66
+ const c = line[i];
67
+ const next = line[i + 1];
68
+ if (state.inBlockComment) {
69
+ if (c === "*" && next === "/") {
70
+ state.inBlockComment = false;
71
+ i += 1;
72
+ }
73
+ continue;
74
+ }
75
+ if (state.inSingle) {
76
+ if (c === "\\") {
77
+ i += 1;
78
+ } else if (c === "'") {
79
+ state.inSingle = false;
80
+ }
81
+ continue;
82
+ }
83
+ if (state.inDouble) {
84
+ if (c === "\\") {
85
+ i += 1;
86
+ } else if (c === '"') {
87
+ state.inDouble = false;
88
+ }
89
+ continue;
90
+ }
91
+ if (c === "/" && next === "/") {
92
+ break; // line comment: rest of the line is never code, and cannot itself open a block comment
93
+ }
94
+ if (c === "/" && next === "*") {
95
+ state.inBlockComment = true;
96
+ i += 1;
97
+ continue;
98
+ }
99
+ if (c === "'") {
100
+ state.inSingle = true;
101
+ continue;
102
+ }
103
+ if (c === '"') {
104
+ state.inDouble = true;
105
+ continue;
106
+ }
107
+ if (c === "{") delta += 1;
108
+ else if (c === "}") delta -= 1;
109
+ }
110
+ return delta;
111
+ }
112
+
113
+ /**
114
+ * Letzte Zeile einer `rpc`-Deklaration.
115
+ *
116
+ * Zwei Formen: die Kurzform endet an ihrem `;` (das bei mehrzeiliger
117
+ * Signatur erst auf einer späteren Zeile steht), die Langform an der
118
+ * schließenden `}` ihres Optionsrumpfs. WP-118: bisher wurde nur die erste
119
+ * Form erkannt, also endete `rpc A(X) returns (Y) {` schon an der ersten
120
+ * `;` IM RUMPF — mitten im Konstrukt, mit fehlender schließender Klammer,
121
+ * und ohne Ledger-Eintrag, weil die Range des Service-Chunks alles
122
+ * abdeckte. Der Klammerstand wird deshalb mitgezählt.
123
+ */
124
+ function findRpcEnd(lines: string[], startIndex: number, hardLimit: number): number {
125
+ const state: ScanState = { inSingle: false, inDouble: false, inBlockComment: false };
126
+ let depth = 0;
127
+ for (let i = startIndex; i < hardLimit; i += 1) {
128
+ const line = lines[i] ?? "";
129
+ const depthBefore = depth;
130
+ depth += scanLine(line, state);
131
+ if (depthBefore > 0 && depth === 0) return i + 1; // Optionsrumpf geschlossen
132
+ if (depthBefore === 0 && depth === 0 && (line.includes(";") || line.includes("}")))
133
+ return i + 1;
134
+ }
135
+ return hardLimit;
136
+ }
137
+
138
+ /** Pushes `pending`'s finished draft (and, for a service, its nested RPC drafts scanned from its now-final range) into `drafts`. */
139
+ function finalizePendingBlock(
140
+ drafts: ChunkDraft[],
141
+ lines: string[],
142
+ pending: { kind: "protobuf_message" | "protobuf_service"; title: string; startLine: number },
143
+ endLine: number,
144
+ ): void {
145
+ drafts.push({ kind: pending.kind, startLine: pending.startLine, endLine, title: pending.title });
146
+ if (pending.kind !== "protobuf_service") return;
147
+ for (let j = pending.startLine - 1; j < endLine; j += 1) {
148
+ const rpc = RPC_RE.exec(lines[j] ?? "");
149
+ if (rpc) {
150
+ const rpcEnd = findRpcEnd(lines, j, endLine);
151
+ drafts.push({
152
+ kind: "protobuf_rpc",
153
+ startLine: j + 1,
154
+ endLine: rpcEnd,
155
+ title: `${pending.title}.${rpc[1]!}`,
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ function protobufDrafts(lines: string[]): ChunkDraft[] {
162
+ const drafts: ChunkDraft[] = [];
163
+ const state: ScanState = { inSingle: false, inDouble: false, inBlockComment: false };
164
+ let depth = 0;
165
+ let pending:
166
+ { kind: "protobuf_message" | "protobuf_service"; title: string; startLine: number } | undefined;
167
+
168
+ for (let i = 0; i < lines.length; i += 1) {
169
+ const line = lines[i] ?? "";
170
+ if (depth === 0 && pending === undefined) {
171
+ const message = MESSAGE_RE.exec(line);
172
+ const service = SERVICE_RE.exec(line);
173
+ if (message) {
174
+ pending = { kind: "protobuf_message", title: message[1]!, startLine: i + 1 };
175
+ } else if (service) {
176
+ pending = { kind: "protobuf_service", title: service[1]!, startLine: i + 1 };
177
+ }
178
+ }
179
+ depth += scanLine(line, state);
180
+ if (pending !== undefined && depth === 0) {
181
+ finalizePendingBlock(drafts, lines, pending, i + 1);
182
+ pending = undefined;
183
+ }
184
+ }
185
+ if (pending !== undefined) {
186
+ // Unterminated top-level block (malformed source, or a still-open
187
+ // string/comment at EOF): degrade to "runs to EOF" rather than
188
+ // dropping it — never silently, see the module doc.
189
+ finalizePendingBlock(drafts, lines, pending, Math.max(lines.length, pending.startLine));
190
+ }
191
+ return drafts;
192
+ }
193
+
194
+ /**
195
+ * Trennzeichen-Konventionen für die Plausibilitätsprüfung (WP-118).
196
+ * Zeichenketten dürfen keine Zeile überschreiten — ein Anführungszeichen,
197
+ * das bis zum Zeilenende offen bleibt, ist in Protobuf bereits ein Befund.
198
+ */
199
+ const PROTOBUF_SCAN_OPTIONS: DelimiterScanOptions = {
200
+ quotes: [
201
+ { open: "'", close: "'", backslashEscapes: true, doubledEscapes: false, spansLines: false },
202
+ { open: '"', close: '"', backslashEscapes: true, doubledEscapes: false, spansLines: false },
203
+ ],
204
+ brackets: [
205
+ { open: "{", close: "}" },
206
+ { open: "(", close: ")" },
207
+ { open: "[", close: "]" },
208
+ ],
209
+ lineComments: ["//"],
210
+ blockComment: { open: "/*", close: "*/" },
211
+ };
212
+
213
+ /**
214
+ * WP-118 (dieselbe Blindstelle wie in `sql.ts`): der Blockzähler dieses
215
+ * Adapters degradiert einen unabgeschlossenen Top-Level-Block zu „läuft
216
+ * bis Dateiende". Die Byte-Abdeckung bleibt dabei lückenlos, der Chunk ist
217
+ * weder leer noch überlappend — der Ledger sah also NICHTS, obwohl der
218
+ * Blockzähler nachweislich aus dem Tritt geraten war. Ein Fragment mit
219
+ * offener Klammer, offener Zeichenkette oder offenem Kommentar ist jetzt
220
+ * ein Befund.
221
+ */
222
+ const checkProtobufPlausibility: ChunkPlausibilityCheck = (chunk) => {
223
+ const imbalances = findDelimiterImbalances(chunk.content, PROTOBUF_SCAN_OPTIONS);
224
+ return imbalances.length === 0 ? undefined : describeDelimiterImbalances(imbalances);
225
+ };
226
+
227
+ function messageName(chunk: ArtifactChunk): string | undefined {
228
+ return chunk.kind === "protobuf_service" ? chunk.title : undefined;
229
+ }
230
+
231
+ export interface ProtobufAdapterOptions {
232
+ /** Overrides how a real skip-ledger finding surfaces from `chunk()` (default: a stderr warning) — see `coverage.ts`'s `SkipLedgerReporter`. */
233
+ onSkipLedgerGap?: SkipLedgerReporter;
234
+ }
235
+
236
+ export function createProtobufAdapter(options: ProtobufAdapterOptions = {}): ArtifactAdapter {
237
+ return {
238
+ supports(type: string, path: string): boolean {
239
+ return type === "protobuf" || (type === "auto" && PROTO_EXTENSION.test(path));
240
+ },
241
+
242
+ async read(input: ArtifactInput): Promise<ArtifactDocument[]> {
243
+ return buildDocuments(input, "protobuf");
244
+ },
245
+
246
+ chunk(document: ArtifactDocument): ArtifactChunk[] {
247
+ const { lines } = splitLines(document.content);
248
+ const index = buildLineIndex(document.content);
249
+ return buildChunksReportingSkips(
250
+ document,
251
+ index,
252
+ protobufDrafts(lines),
253
+ options.onSkipLedgerGap,
254
+ checkProtobufPlausibility,
255
+ );
256
+ },
257
+
258
+ /**
259
+ * Heuristic (architecture.md §20): a gRPC service `FooService` often
260
+ * backs a generated/handwritten server symbol named `FooServiceServer`
261
+ * or `FooServiceImpl`. Both are offered as separate low-confidence
262
+ * hints — this is a naming guess, not a resolved reference.
263
+ */
264
+ links(document: ArtifactDocument): ArtifactLink[] {
265
+ const { lines } = splitLines(document.content);
266
+ const index = buildLineIndex(document.content);
267
+ const chunks = buildChunks(document, index, protobufDrafts(lines));
268
+ const links: ArtifactLink[] = [];
269
+ for (const chunk of chunks) {
270
+ const name = messageName(chunk);
271
+ if (name === undefined) continue;
272
+ for (const suffix of ["Server", "Impl"]) {
273
+ links.push({
274
+ fromChunkId: chunk.chunkId,
275
+ targetKind: "symbol",
276
+ targetName: `${name}${suffix}`,
277
+ heuristic: true,
278
+ confidence: 0.3,
279
+ note: `naming-convention guess: gRPC service "${name}" → generated/handwritten server symbol`,
280
+ });
281
+ }
282
+ }
283
+ return links;
284
+ },
285
+ };
286
+ }