@fcon-tech/portolan 0.4.5

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/adapters/README.md +226 -0
  4. package/adapters/omp/portolan-mcp +19 -0
  5. package/adapters/opencode/expedition-launcher +70 -0
  6. package/adapters/opencode/install.test.ts +105 -0
  7. package/adapters/opencode/install.ts +357 -0
  8. package/adapters/pi/portolan-mcp +19 -0
  9. package/adapters/scheduling/night-watch.cron +23 -0
  10. package/core/schema/chart.schema.json +154 -0
  11. package/core/src/bin/portolan.ts +84 -0
  12. package/core/src/chart-io.rollback-fixture.ts +55 -0
  13. package/core/src/chart-io.ts +121 -0
  14. package/core/src/chart-store.ts +137 -0
  15. package/core/src/chartroom/cli.ts +63 -0
  16. package/core/src/chartroom/render.ts +213 -0
  17. package/core/src/chartroom/review-template.html +232 -0
  18. package/core/src/chartroom/review.ts +109 -0
  19. package/core/src/chartroom/template.html +1090 -0
  20. package/core/src/fan-in.ts +84 -0
  21. package/core/src/harbor/chat-format.ts +154 -0
  22. package/core/src/harbor/cli.ts +178 -0
  23. package/core/src/harbor/errors.ts +22 -0
  24. package/core/src/harbor/fingerprint.ts +29 -0
  25. package/core/src/harbor/history.ts +178 -0
  26. package/core/src/harbor/launcher.ts +155 -0
  27. package/core/src/harbor/night-policy.ts +64 -0
  28. package/core/src/harbor/proposals.ts +324 -0
  29. package/core/src/harbor/run.ts +72 -0
  30. package/core/src/harbor/settings.ts +108 -0
  31. package/core/src/harbor/snapshot.ts +187 -0
  32. package/core/src/harbor/watch.ts +103 -0
  33. package/core/src/index.ts +28 -0
  34. package/core/src/notices.ts +117 -0
  35. package/core/src/perimeter.ts +44 -0
  36. package/core/src/server/adapter-boundary.ts +66 -0
  37. package/core/src/server/main.ts +27 -0
  38. package/core/src/server/registry.ts +609 -0
  39. package/core/src/server/server.ts +123 -0
  40. package/core/src/server/test-harness.ts +161 -0
  41. package/core/src/sheets.ts +151 -0
  42. package/core/src/staleness.ts +203 -0
  43. package/core/src/tools/log.ts +215 -0
  44. package/core/src/tools/manifests.ts +912 -0
  45. package/core/src/tools/neighborhood.ts +423 -0
  46. package/core/src/tools/shared.ts +72 -0
  47. package/core/src/tools/sound.ts +634 -0
  48. package/core/src/tools/sweep.ts +198 -0
  49. package/core/src/tools/symbols.ts +176 -0
  50. package/core/src/tools/trust-report.ts +193 -0
  51. package/core/src/types.ts +162 -0
  52. package/core/src/validate.ts +106 -0
  53. package/package.json +34 -0
  54. package/skill/SKILL.md +279 -0
  55. package/skill/examples/sailing-directions-example.md +35 -0
  56. package/skill/sailing-directions.template.md +59 -0
  57. package/skill/verify/checks.ts +476 -0
  58. package/skill/verify/dry-run.ts +738 -0
  59. package/skill/verify/fixture.ts +128 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * `manifests`: cheap deterministic facts from the five supported manifest
3
+ * kinds — go.mod, pom.xml, package.json, Cargo.toml, pubspec.yaml — and no
4
+ * other file kind, and no path outside the target: a cited path is contained
5
+ * by the province perimeter (core/src/perimeter.ts), and an escaping path is
6
+ * reported, never read. Manifest files are the only structural parsing
7
+ * Portolan performs. Every fact carries the manifest file path, its manifest
8
+ * key, and the trust label `charted`. Unsupported kinds are reported, not
9
+ * guessed; unparseable files fail loudly with zero partial facts.
10
+ * specs/tools/spec.md
11
+ *
12
+ * Reader note: design.md decision 3 sketched small third-party
13
+ * XML/TOML/YAML parsers; this execution ships minimal in-tree readers
14
+ * instead, honoring the change's no-new-runtime-dependencies constraint.
15
+ * Manifest parsing stays the sanctioned exception to "no hand-written
16
+ * parsers"; source-code parsing remains forbidden.
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ import { basename } from "node:path";
20
+ import type { Anchor } from "../types";
21
+ import { resolveInsideTarget } from "../perimeter";
22
+
23
+ export const MANIFEST_KINDS = [
24
+ "go.mod",
25
+ "pom.xml",
26
+ "package.json",
27
+ "Cargo.toml",
28
+ "pubspec.yaml",
29
+ ] as const;
30
+
31
+ export type ManifestKind = (typeof MANIFEST_KINDS)[number];
32
+
33
+ /** One deterministic fact read from a manifest, anchored to its key. */
34
+ export interface ManifestFact {
35
+ trust: "charted";
36
+ /** The manifest key the fact came from, e.g. "dependencies.left-pad". */
37
+ key: string;
38
+ /** The fact's value; "" marks a declared dependency with no version. */
39
+ value: string;
40
+ anchor: Anchor & { type: "manifest" };
41
+ }
42
+
43
+ export interface ManifestDependency {
44
+ name: string;
45
+ version?: string;
46
+ fact: ManifestFact;
47
+ }
48
+
49
+ export interface ManifestReadResult {
50
+ /** Path as given, relative to the target root. */
51
+ path: string;
52
+ kind: ManifestKind;
53
+ name?: string;
54
+ version?: string;
55
+ dependencies: ManifestDependency[];
56
+ facts: ManifestFact[];
57
+ }
58
+
59
+ /** A manifest file whose kind is not supported: reported, never guessed. */
60
+ export interface ManifestUnsupported {
61
+ path: string;
62
+ supported: false;
63
+ reason: string;
64
+ }
65
+
66
+ export type ManifestOutcome = ManifestReadResult | ManifestUnsupported;
67
+
68
+ export class ManifestParseError extends Error {
69
+ readonly path: string;
70
+ constructor(path: string, message: string) {
71
+ super(`manifests: ${message}`);
72
+ this.name = "ManifestParseError";
73
+ this.path = path;
74
+ }
75
+ }
76
+
77
+ /** What every reader extracts; keys are per-format manifest keys. */
78
+ interface ParsedManifest {
79
+ name?: string;
80
+ version?: string;
81
+ group?: string;
82
+ dependencies: Array<{ section: string; name: string; version?: string }>;
83
+ }
84
+
85
+ /** The manifest key a format uses for the component's own name/version. */
86
+ const NAME_KEY: Record<ManifestKind, string> = {
87
+ "go.mod": "module",
88
+ "pom.xml": "project.artifactId",
89
+ "package.json": "name",
90
+ "Cargo.toml": "package.name",
91
+ "pubspec.yaml": "name",
92
+ };
93
+
94
+ const VERSION_KEY: Record<ManifestKind, string | undefined> = {
95
+ "go.mod": undefined, // a go.mod declares no component version
96
+ "pom.xml": "project.version",
97
+ "package.json": "version",
98
+ "Cargo.toml": "package.version",
99
+ "pubspec.yaml": "version",
100
+ };
101
+
102
+ const GROUP_KEY: Record<ManifestKind, string | undefined> = {
103
+ "go.mod": undefined,
104
+ "pom.xml": "project.groupId",
105
+ "package.json": undefined,
106
+ "Cargo.toml": undefined,
107
+ "pubspec.yaml": undefined,
108
+ };
109
+
110
+ /** Classify a path by basename; undefined when not a supported kind. */
111
+ export function manifestKindOf(path: string): ManifestKind | undefined {
112
+ const name = basename(path);
113
+ return (MANIFEST_KINDS as readonly string[]).includes(name)
114
+ ? (name as ManifestKind)
115
+ : undefined;
116
+ }
117
+
118
+ /** Read one manifest file from the target; the only structural parsing. */
119
+ export function readManifest(targetRoot: string, path: string): ManifestOutcome {
120
+ const kind = manifestKindOf(path);
121
+ if (kind === undefined) {
122
+ return {
123
+ path,
124
+ supported: false,
125
+ reason:
126
+ `unsupported manifest kind "${basename(path)}"; supported kinds are ` +
127
+ MANIFEST_KINDS.join(", "),
128
+ };
129
+ }
130
+ const abs = resolveInsideTarget(targetRoot, path);
131
+ if (abs === undefined) {
132
+ return {
133
+ path,
134
+ supported: false,
135
+ reason: "the path escapes the target root — nothing outside the province is read",
136
+ };
137
+ }
138
+ const text = readFileSync(abs, "utf8");
139
+ const parsed = parseManifest(kind, text, path);
140
+ return assembleFacts(kind, path, parsed);
141
+ }
142
+
143
+ function parseManifest(kind: ManifestKind, text: string, path: string): ParsedManifest {
144
+ try {
145
+ switch (kind) {
146
+ case "package.json":
147
+ return parsePackageJson(text);
148
+ case "go.mod":
149
+ return parseGoMod(text);
150
+ case "pom.xml":
151
+ return parsePom(text);
152
+ case "Cargo.toml":
153
+ return parseCargoToml(text);
154
+ case "pubspec.yaml":
155
+ return parsePubspec(text);
156
+ }
157
+ } catch (err) {
158
+ if (err instanceof ManifestParseError) throw err;
159
+ const reason = err instanceof Error ? err.message : String(err);
160
+ throw new ManifestParseError(path, `failed to parse ${path}: ${reason}`);
161
+ }
162
+ }
163
+
164
+ function assembleFacts(
165
+ kind: ManifestKind,
166
+ path: string,
167
+ parsed: ParsedManifest,
168
+ ): ManifestReadResult {
169
+ const facts: ManifestFact[] = [];
170
+ const fact = (key: string, value: string): ManifestFact => ({
171
+ trust: "charted",
172
+ key,
173
+ value,
174
+ anchor: { type: "manifest", path, key },
175
+ });
176
+
177
+ if (parsed.name !== undefined) facts.push(fact(NAME_KEY[kind], parsed.name));
178
+ if (VERSION_KEY[kind] !== undefined && parsed.version !== undefined) {
179
+ facts.push(fact(VERSION_KEY[kind]!, parsed.version));
180
+ }
181
+ if (GROUP_KEY[kind] !== undefined && parsed.group !== undefined) {
182
+ facts.push(fact(GROUP_KEY[kind]!, parsed.group));
183
+ }
184
+
185
+ const dependencies: ManifestDependency[] = parsed.dependencies.map((dep) => {
186
+ const depFact = fact(`${dep.section}.${dep.name}`, dep.version ?? "");
187
+ facts.push(depFact);
188
+ return {
189
+ name: dep.name,
190
+ ...(dep.version !== undefined ? { version: dep.version } : {}),
191
+ fact: depFact,
192
+ };
193
+ });
194
+
195
+ return {
196
+ path,
197
+ kind,
198
+ ...(parsed.name !== undefined ? { name: parsed.name } : {}),
199
+ ...(parsed.version !== undefined ? { version: parsed.version } : {}),
200
+ dependencies,
201
+ facts,
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // package.json
207
+ // ---------------------------------------------------------------------------
208
+
209
+ const NPM_SECTIONS = [
210
+ "dependencies",
211
+ "devDependencies",
212
+ "peerDependencies",
213
+ "optionalDependencies",
214
+ ] as const;
215
+
216
+ function parsePackageJson(text: string): ParsedManifest {
217
+ let doc: unknown;
218
+ try {
219
+ doc = JSON.parse(text);
220
+ } catch (err) {
221
+ throw new Error(`not valid JSON (${(err as Error).message})`);
222
+ }
223
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
224
+ throw new Error("top level is not a JSON object");
225
+ }
226
+ const obj = doc as Record<string, unknown>;
227
+ const dependencies: ParsedManifest["dependencies"] = [];
228
+ for (const section of NPM_SECTIONS) {
229
+ const table = obj[section];
230
+ if (table === undefined) continue;
231
+ if (typeof table !== "object" || table === null || Array.isArray(table)) {
232
+ throw new Error(`"${section}" is not a dependency map`);
233
+ }
234
+ for (const [name, value] of Object.entries(table)) {
235
+ dependencies.push({
236
+ section,
237
+ name,
238
+ version: typeof value === "string" ? value : undefined,
239
+ });
240
+ }
241
+ }
242
+ return {
243
+ name: typeof obj.name === "string" ? obj.name : undefined,
244
+ version: typeof obj.version === "string" ? obj.version : undefined,
245
+ dependencies,
246
+ };
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // go.mod (line-oriented; its grammar is line-shaped)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function parseGoMod(text: string): ParsedManifest {
254
+ const dependencies: ParsedManifest["dependencies"] = [];
255
+ let name: string | undefined;
256
+ let block: "require" | "skip" | undefined;
257
+ const lines = text.split("\n");
258
+ for (let i = 0; i < lines.length; i += 1) {
259
+ let line = lines[i]!;
260
+ const comment = line.indexOf("//");
261
+ if (comment >= 0) line = line.slice(0, comment);
262
+ line = line.trim();
263
+ if (line.length === 0) continue;
264
+
265
+ if (block !== undefined) {
266
+ if (line === ")") {
267
+ block = undefined;
268
+ continue;
269
+ }
270
+ if (block === "skip") continue;
271
+ const tokens = line.split(/\s+/);
272
+ if (tokens.length !== 2) {
273
+ throw new Error(
274
+ `line ${i + 1}: expected "<module> <version>" inside require block, got "${line}"`,
275
+ );
276
+ }
277
+ dependencies.push({ section: "require", name: tokens[0]!, version: tokens[1] });
278
+ continue;
279
+ }
280
+
281
+ const tokens = line.split(/\s+/);
282
+ const head = tokens[0]!;
283
+ if (head === "module") {
284
+ if (tokens.length !== 2) {
285
+ throw new Error(`line ${i + 1}: module directive needs exactly one path`);
286
+ }
287
+ name = tokens[1];
288
+ continue;
289
+ }
290
+ if (head === "require") {
291
+ if (tokens.length === 2 && tokens[1] === "(") {
292
+ block = "require";
293
+ continue;
294
+ }
295
+ if (tokens.length === 3) {
296
+ dependencies.push({ section: "require", name: tokens[1]!, version: tokens[2] });
297
+ continue;
298
+ }
299
+ throw new Error(`line ${i + 1}: cannot parse require directive "${line}"`);
300
+ }
301
+ // go, toolchain, and any other directive carries no manifest fact;
302
+ // parenthesized blocks (replace, exclude, retract, ...) are skipped.
303
+ if (tokens.length === 2 && tokens[1] === "(") {
304
+ block = "skip";
305
+ }
306
+ }
307
+ if (block !== undefined) {
308
+ throw new Error(`unterminated ${block} block at end of file`);
309
+ }
310
+ return { name, version: undefined, dependencies };
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // pom.xml (minimal well-formedness-checking XML scanner)
315
+ // ---------------------------------------------------------------------------
316
+
317
+ function decodeXmlEntities(s: string): string {
318
+ return s
319
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h: string) => String.fromCodePoint(parseInt(h, 16)))
320
+ .replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(parseInt(d, 10)))
321
+ .replace(/&lt;/g, "<")
322
+ .replace(/&gt;/g, ">")
323
+ .replace(/&quot;/g, '"')
324
+ .replace(/&apos;/g, "'")
325
+ .replace(/&amp;/g, "&");
326
+ }
327
+
328
+ /** Dependency-bearing element paths inside a pom (keys stay source-faithful). */
329
+ const POM_DEP_PREFIXES = [
330
+ "project/dependencies/dependency",
331
+ "project/dependencyManagement/dependencies/dependency",
332
+ ] as const;
333
+
334
+ function parsePom(text: string): ParsedManifest {
335
+ let group: string | undefined;
336
+ let name: string | undefined;
337
+ let version: string | undefined;
338
+ const dependencies: ParsedManifest["dependencies"] = [];
339
+
340
+ const stack: string[] = [];
341
+ let textBuf = "";
342
+ let pendingDep: { name?: string; version?: string } = {};
343
+ let i = 0;
344
+ const n = text.length;
345
+
346
+ const finalize = (path: string, content: string): void => {
347
+ const value = decodeXmlEntities(content.trim());
348
+ if (path === "project/groupId") group = value;
349
+ else if (path === "project/artifactId") name = value;
350
+ else if (path === "project/version") version = value;
351
+ else {
352
+ for (const prefix of POM_DEP_PREFIXES) {
353
+ if (path === `${prefix}/artifactId`) pendingDep.name = value;
354
+ else if (path === `${prefix}/version`) pendingDep.version = value;
355
+ }
356
+ }
357
+ };
358
+
359
+ while (i < n) {
360
+ const lt = text.indexOf("<", i);
361
+ if (lt < 0) break;
362
+ textBuf += text.slice(i, lt);
363
+ i = lt;
364
+
365
+ if (text.startsWith("<!--", i)) {
366
+ const end = text.indexOf("-->", i + 4);
367
+ if (end < 0) throw new Error("unterminated XML comment");
368
+ i = end + 3;
369
+ } else if (text.startsWith("<![CDATA[", i)) {
370
+ const end = text.indexOf("]]>", i + 9);
371
+ if (end < 0) throw new Error("unterminated CDATA section");
372
+ textBuf += text.slice(i + 9, end);
373
+ i = end + 3;
374
+ } else if (text.startsWith("<?", i)) {
375
+ const end = text.indexOf("?>", i + 2);
376
+ if (end < 0) throw new Error("unterminated processing instruction");
377
+ i = end + 2;
378
+ } else if (text.startsWith("<!", i)) {
379
+ let depth = 0;
380
+ let j = i + 2;
381
+ while (j < n) {
382
+ const c = text[j]!;
383
+ if (c === "[") depth += 1;
384
+ else if (c === "]") depth -= 1;
385
+ else if (c === ">" && depth <= 0) break;
386
+ j += 1;
387
+ }
388
+ if (j >= n) throw new Error("unterminated <! declaration (DOCTYPE?)");
389
+ i = j + 1;
390
+ } else if (text.startsWith("</", i)) {
391
+ const end = text.indexOf(">", i);
392
+ if (end < 0) throw new Error("unterminated closing tag");
393
+ const tagName = text.slice(i + 2, end).trim();
394
+ if (tagName.length === 0 || /[\s/<>=]/.test(tagName)) {
395
+ throw new Error(`malformed closing tag "${tagName}"`);
396
+ }
397
+ const open = stack.pop();
398
+ if (open !== tagName) {
399
+ throw new Error(
400
+ `mismatched closing tag </${tagName}> (open element: <${open ?? "none"}>)`,
401
+ );
402
+ }
403
+ const path = stack.length === 0 ? tagName : `${stack.join("/")}/${tagName}`;
404
+ finalize(path, textBuf);
405
+ const depPrefix = POM_DEP_PREFIXES.find((p) => path === p);
406
+ if (depPrefix !== undefined) {
407
+ if (pendingDep.name === undefined) {
408
+ throw new Error("dependency without an artifactId");
409
+ }
410
+ dependencies.push({
411
+ section: depPrefix === "project/dependencies/dependency"
412
+ ? "project.dependencies"
413
+ : "project.dependencyManagement.dependencies",
414
+ name: pendingDep.name,
415
+ version: pendingDep.version,
416
+ });
417
+ pendingDep = {};
418
+ }
419
+ textBuf = "";
420
+ i = end + 1;
421
+ } else {
422
+ // opening tag: read the name, skip attributes, detect self-closing
423
+ let j = i + 1;
424
+ const nameStart = j;
425
+ while (j < n && !/[\s/>]/.test(text[j]!)) j += 1;
426
+ const tagName = text.slice(nameStart, j);
427
+ if (tagName.length === 0) throw new Error(`malformed tag at offset ${i}`);
428
+ let quote: string | null = null;
429
+ let selfClosing = false;
430
+ while (j < n) {
431
+ const c = text[j]!;
432
+ if (quote !== null) {
433
+ if (c === quote) quote = null;
434
+ } else if (c === '"' || c === "'") quote = c;
435
+ else if (c === "/" && text[j + 1] === ">") {
436
+ selfClosing = true;
437
+ break;
438
+ } else if (c === ">") break;
439
+ j += 1;
440
+ }
441
+ if (j >= n) throw new Error(`unterminated tag <${tagName}`);
442
+ i = selfClosing ? j + 2 : j + 1;
443
+ textBuf = "";
444
+ if (selfClosing) {
445
+ const path = stack.length === 0 ? tagName : `${stack.join("/")}/${tagName}`;
446
+ finalize(path, "");
447
+ } else {
448
+ stack.push(tagName);
449
+ }
450
+ }
451
+ }
452
+ if (stack.length > 0) {
453
+ throw new Error(`unexpected end of file: <${stack[stack.length - 1]}> is never closed`);
454
+ }
455
+ return { name, version, group, dependencies };
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Cargo.toml (a small strict-subset TOML parser; scalars stay raw)
460
+ // ---------------------------------------------------------------------------
461
+
462
+ /** A TOML value we only ever carry around: numbers, booleans, dates. */
463
+ class TomlRaw {
464
+ constructor(readonly raw: string) {}
465
+ }
466
+
467
+ interface TomlTable {
468
+ [key: string]: TomlValue;
469
+ }
470
+
471
+ type TomlValue = string | TomlRaw | TomlValue[] | TomlTable;
472
+
473
+ const TOML_SIMPLE_ESCAPES: Record<string, string> = {
474
+ n: "\n",
475
+ t: "\t",
476
+ r: "\r",
477
+ '"': '"',
478
+ "\\": "\\",
479
+ b: "\b",
480
+ f: "\f",
481
+ };
482
+
483
+ function parseCargoToml(text: string): ParsedManifest {
484
+ const root: TomlTable = {};
485
+ let current: TomlTable = root;
486
+ let i = 0;
487
+ const n = text.length;
488
+
489
+ const skipWs = (includeNewlines: boolean): void => {
490
+ for (;;) {
491
+ const c = text[i];
492
+ if (c === undefined) return;
493
+ if (c === " " || c === "\t" || (includeNewlines && (c === "\n" || c === "\r"))) {
494
+ i += 1;
495
+ continue;
496
+ }
497
+ if (c === "#") {
498
+ while (i < n && text[i] !== "\n") i += 1;
499
+ continue;
500
+ }
501
+ return;
502
+ }
503
+ };
504
+
505
+ const unescapeBasic = (body: string): string => {
506
+ let out = "";
507
+ for (let k = 0; k < body.length; k += 1) {
508
+ const c = body[k]!;
509
+ if (c !== "\\") {
510
+ out += c;
511
+ continue;
512
+ }
513
+ const esc = body[k + 1];
514
+ if (esc === undefined) throw new Error("dangling escape in multiline string");
515
+ if (esc === "\n" || esc === "\r" || esc === " " || esc === "\t") {
516
+ // line-ending backslash: swallow the whitespace that follows
517
+ while (k < body.length && /\s/.test(body[k]!)) k += 1;
518
+ k -= 1;
519
+ continue;
520
+ }
521
+ if (TOML_SIMPLE_ESCAPES[esc] !== undefined) {
522
+ out += TOML_SIMPLE_ESCAPES[esc]!;
523
+ k += 1;
524
+ continue;
525
+ }
526
+ if (esc === "u" || esc === "U") {
527
+ const digits = esc === "u" ? 4 : 8;
528
+ const hex = body.slice(k + 2, k + 2 + digits);
529
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length < digits) {
530
+ throw new Error(`invalid \\${esc} escape`);
531
+ }
532
+ out += String.fromCodePoint(parseInt(hex, 16));
533
+ k += 1 + digits;
534
+ continue;
535
+ }
536
+ throw new Error(`invalid escape sequence \\${esc}`);
537
+ }
538
+ return out;
539
+ };
540
+
541
+ const parseString = (): string => {
542
+ const quote = text[i]!;
543
+ const triple = quote.repeat(3);
544
+ if (text.startsWith(triple, i)) {
545
+ const close = text.indexOf(triple, i + 3);
546
+ if (close < 0) throw new Error("unterminated multiline string");
547
+ let body = text.slice(i + 3, close);
548
+ if (body.startsWith("\r\n")) body = body.slice(2);
549
+ else if (body.startsWith("\n")) body = body.slice(1);
550
+ i = close + 3;
551
+ return quote === '"' ? unescapeBasic(body) : body;
552
+ }
553
+ i += 1;
554
+ let out = "";
555
+ while (i < n) {
556
+ const c = text[i]!;
557
+ if (c === quote) {
558
+ i += 1;
559
+ return out;
560
+ }
561
+ if (c === "\n") throw new Error("unterminated string (newline before closing quote)");
562
+ if (quote === '"' && c === "\\") {
563
+ const esc = text[i + 1];
564
+ if (esc === undefined) throw new Error("unterminated escape sequence");
565
+ if (TOML_SIMPLE_ESCAPES[esc] !== undefined) {
566
+ out += TOML_SIMPLE_ESCAPES[esc]!;
567
+ i += 2;
568
+ continue;
569
+ }
570
+ if (esc === "u" || esc === "U") {
571
+ const digits = esc === "u" ? 4 : 8;
572
+ const hex = text.slice(i + 2, i + 2 + digits);
573
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length < digits) {
574
+ throw new Error(`invalid \\${esc} escape`);
575
+ }
576
+ out += String.fromCodePoint(parseInt(hex, 16));
577
+ i += 2 + digits;
578
+ continue;
579
+ }
580
+ throw new Error(`invalid escape sequence \\${esc}`);
581
+ }
582
+ out += c;
583
+ i += 1;
584
+ }
585
+ throw new Error("unterminated string (end of file)");
586
+ };
587
+
588
+ const parseKeyPart = (): string => {
589
+ const c = text[i];
590
+ if (c === '"' || c === "'") return parseString();
591
+ const start = i;
592
+ while (i < n && /[A-Za-z0-9_-]/.test(text[i]!)) i += 1;
593
+ if (i === start) throw new Error(`expected a key at offset ${i}`);
594
+ return text.slice(start, i);
595
+ };
596
+
597
+ const parseArray = (): TomlValue[] => {
598
+ i += 1; // consume [
599
+ const items: TomlValue[] = [];
600
+ for (;;) {
601
+ skipWs(true);
602
+ const c = text[i];
603
+ if (c === undefined) throw new Error("unterminated array");
604
+ if (c === "]") {
605
+ i += 1;
606
+ return items;
607
+ }
608
+ items.push(parseValue());
609
+ skipWs(true);
610
+ const after = text[i];
611
+ if (after === ",") {
612
+ i += 1;
613
+ continue;
614
+ }
615
+ if (after === "]") {
616
+ i += 1;
617
+ return items;
618
+ }
619
+ throw new Error(`expected "," or "]" in array at offset ${i}`);
620
+ }
621
+ };
622
+
623
+ const parseInlineTable = (): TomlTable => {
624
+ i += 1; // consume {
625
+ const table: TomlTable = {};
626
+ skipWs(true);
627
+ if (text[i] === "}") {
628
+ i += 1;
629
+ return table;
630
+ }
631
+ for (;;) {
632
+ skipWs(true);
633
+ assignKeyValue(table);
634
+ skipWs(true);
635
+ const c = text[i];
636
+ if (c === ",") {
637
+ i += 1;
638
+ continue;
639
+ }
640
+ if (c === "}") {
641
+ i += 1;
642
+ return table;
643
+ }
644
+ throw new Error(`expected "," or "}" in inline table at offset ${i}`);
645
+ }
646
+ };
647
+
648
+ const parseValue = (): TomlValue => {
649
+ skipWs(false);
650
+ const c = text[i];
651
+ if (c === undefined) throw new Error("expected a value, found end of file");
652
+ if (c === '"' || c === "'") return parseString();
653
+ if (c === "[") return parseArray();
654
+ if (c === "{") return parseInlineTable();
655
+ const start = i;
656
+ while (i < n && !",]}#\n\r".includes(text[i]!)) i += 1;
657
+ const raw = text.slice(start, i).trim();
658
+ if (raw.length === 0) throw new Error(`expected a value at offset ${start}`);
659
+ return new TomlRaw(raw);
660
+ };
661
+
662
+ function setPath(table: TomlTable, parts: string[], value: TomlValue): void {
663
+ let node: TomlTable = table;
664
+ for (let k = 0; k < parts.length - 1; k += 1) {
665
+ const part = parts[k]!;
666
+ const existing = node[part];
667
+ if (existing === undefined) {
668
+ const next: TomlTable = {};
669
+ node[part] = next;
670
+ node = next;
671
+ } else if (existing instanceof TomlRaw || Array.isArray(existing)) {
672
+ throw new Error(`key conflict at "${parts.join(".")}"`);
673
+ } else {
674
+ node = existing as TomlTable;
675
+ }
676
+ }
677
+ const last = parts[parts.length - 1]!;
678
+ if (node[last] !== undefined) throw new Error(`duplicate key "${parts.join(".")}"`);
679
+ node[last] = value;
680
+ }
681
+
682
+ function assignKeyValue(target: TomlTable): void {
683
+ const parts = [parseKeyPart()];
684
+ skipWs(false);
685
+ while (text[i] === ".") {
686
+ i += 1;
687
+ skipWs(false);
688
+ parts.push(parseKeyPart());
689
+ skipWs(false);
690
+ }
691
+ if (text[i] !== "=") {
692
+ throw new Error(`expected "=" after key "${parts.join(".")}" at offset ${i}`);
693
+ }
694
+ i += 1;
695
+ setPath(target, parts, parseValue());
696
+ }
697
+
698
+ for (;;) {
699
+ skipWs(true);
700
+ if (i >= n) break;
701
+ if (text[i] === "[") {
702
+ const arrayTable = text.startsWith("[[", i);
703
+ i += arrayTable ? 2 : 1;
704
+ skipWs(false);
705
+ const parts = [parseKeyPart()];
706
+ skipWs(false);
707
+ while (text[i] === ".") {
708
+ i += 1;
709
+ skipWs(false);
710
+ parts.push(parseKeyPart());
711
+ skipWs(false);
712
+ }
713
+ const closer = arrayTable ? "]]" : "]";
714
+ if (!text.startsWith(closer, i)) {
715
+ throw new Error(`expected "${closer}" after table name at offset ${i}`);
716
+ }
717
+ i += closer.length;
718
+ let node: TomlTable = root;
719
+ for (let k = 0; k < parts.length - 1; k += 1) {
720
+ const part = parts[k]!;
721
+ const existing = node[part];
722
+ if (existing === undefined) {
723
+ const next: TomlTable = {};
724
+ node[part] = next;
725
+ node = next;
726
+ } else if (existing instanceof TomlRaw || Array.isArray(existing)) {
727
+ throw new Error(`table "${parts.join(".")}" conflicts with an existing value`);
728
+ } else {
729
+ node = existing as TomlTable;
730
+ }
731
+ }
732
+ const last = parts[parts.length - 1]!;
733
+ if (arrayTable) {
734
+ const list: TomlValue[] = Array.isArray(node[last]) ? (node[last] as TomlValue[]) : [];
735
+ node[last] = list;
736
+ const table: TomlTable = {};
737
+ list.push(table);
738
+ current = table;
739
+ } else {
740
+ const existing = node[last];
741
+ if (existing === undefined || existing instanceof TomlRaw) {
742
+ const table: TomlTable = {};
743
+ node[last] = table;
744
+ current = table;
745
+ } else if (Array.isArray(existing)) {
746
+ throw new Error(`table "${parts.join(".")}" conflicts with an array of tables`);
747
+ } else {
748
+ current = existing as TomlTable;
749
+ }
750
+ }
751
+ continue;
752
+ }
753
+ assignKeyValue(current);
754
+ skipWs(false);
755
+ const c = text[i];
756
+ if (c !== undefined && c !== "\n" && c !== "\r") {
757
+ throw new Error(`unexpected trailing input at offset ${i}`);
758
+ }
759
+ }
760
+
761
+ const asTable = (v: TomlValue | undefined): TomlTable | undefined =>
762
+ v !== undefined && typeof v === "object" && !Array.isArray(v) && !(v instanceof TomlRaw)
763
+ ? (v as TomlTable)
764
+ : undefined;
765
+
766
+ const pkg = asTable(root["package"]);
767
+ const dependencies: ParsedManifest["dependencies"] = [];
768
+ for (const section of ["dependencies", "dev-dependencies", "build-dependencies"] as const) {
769
+ const table = asTable(root[section]);
770
+ if (table === undefined) continue;
771
+ for (const [depName, value] of Object.entries(table)) {
772
+ let version: string | undefined;
773
+ if (typeof value === "string") version = value;
774
+ else {
775
+ const inline = asTable(value);
776
+ const v = inline?.["version"];
777
+ if (typeof v === "string") version = v;
778
+ }
779
+ dependencies.push({ section, name: depName, version });
780
+ }
781
+ }
782
+ return {
783
+ name: typeof pkg?.["name"] === "string" ? pkg["name"] : undefined,
784
+ version: typeof pkg?.["version"] === "string" ? pkg["version"] : undefined,
785
+ dependencies,
786
+ };
787
+ }
788
+
789
+ // ---------------------------------------------------------------------------
790
+ // pubspec.yaml (restricted reader: flat keys plus dependency maps)
791
+ // ---------------------------------------------------------------------------
792
+
793
+ function stripYamlComment(line: string): string {
794
+ let quote: string | null = null;
795
+ for (let k = 0; k < line.length; k += 1) {
796
+ const c = line[k]!;
797
+ if (quote !== null) {
798
+ if (c === quote) quote = null;
799
+ continue;
800
+ }
801
+ if (c === '"' || c === "'") {
802
+ quote = c;
803
+ continue;
804
+ }
805
+ if (c === "#" && (k === 0 || line[k - 1] === " " || line[k - 1] === "\t")) {
806
+ return line.slice(0, k);
807
+ }
808
+ }
809
+ return line;
810
+ }
811
+
812
+ function stripYamlQuotes(value: string): string {
813
+ const t = value.trim();
814
+ if (
815
+ t.length >= 2 &&
816
+ ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))
817
+ ) {
818
+ return t.slice(1, -1);
819
+ }
820
+ return t;
821
+ }
822
+
823
+ const YAML_KEY = /^(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+)):(?:[ \t]+(.*))?$/;
824
+
825
+ function parsePubspec(text: string): ParsedManifest {
826
+ const dependencies: ParsedManifest["dependencies"] = [];
827
+ let name: string | undefined;
828
+ let version: string | undefined;
829
+
830
+ const lines = text.split("\n").map(stripYamlComment);
831
+ let k = 0;
832
+ while (k < lines.length) {
833
+ const line = lines[k]!;
834
+ if (line.trim().length === 0) {
835
+ k += 1;
836
+ continue;
837
+ }
838
+ if (/^[ \t]/.test(line)) {
839
+ throw new Error(`line ${k + 1}: unexpected indented line "${line.trim()}" outside any block`);
840
+ }
841
+ const keyLine = k + 1;
842
+ const match = YAML_KEY.exec(line.replace(/\r$/, ""));
843
+ if (match === null) {
844
+ throw new Error(`line ${keyLine}: expected "<key>:", got "${line.trim()}"`);
845
+ }
846
+ const key = match[1] ?? match[2] ?? match[3]!;
847
+ const inlineValue =
848
+ match[4] !== undefined && match[4].length > 0 ? stripYamlQuotes(match[4]) : undefined;
849
+ k += 1;
850
+
851
+ // gather the block that belongs to this key (deeper-indented lines)
852
+ const block: Array<{ indent: number; text: string; lineNo: number }> = [];
853
+ while (k < lines.length) {
854
+ const bl = lines[k]!;
855
+ if (bl.trim().length === 0) {
856
+ k += 1;
857
+ continue;
858
+ }
859
+ const indent = bl.length - bl.trimStart().length;
860
+ if (indent === 0) break;
861
+ block.push({ indent, text: bl.replace(/\r$/, "").trimEnd(), lineNo: k + 1 });
862
+ k += 1;
863
+ }
864
+
865
+ if (key === "name" || key === "version") {
866
+ if (inlineValue === undefined) {
867
+ throw new Error(`line ${keyLine}: "${key}" needs a scalar value`);
868
+ }
869
+ if (key === "name") name = inlineValue;
870
+ else version = inlineValue;
871
+ continue;
872
+ }
873
+ if (key !== "dependencies" && key !== "dev_dependencies") {
874
+ continue; // environment, description, flutter, ... carry no v1 facts
875
+ }
876
+ if (inlineValue !== undefined) {
877
+ throw new Error(`line ${keyLine}: "${key}" must be a mapping`);
878
+ }
879
+ const section = key;
880
+ if (block.length === 0) continue;
881
+ const baseIndent = Math.min(...block.map((b) => b.indent));
882
+ let currentDep: { index: number; version?: string } | null = null;
883
+ for (const entry of block) {
884
+ if (entry.indent === baseIndent) {
885
+ const m = YAML_KEY.exec(entry.text.trim());
886
+ if (m === null) {
887
+ throw new Error(
888
+ `line ${entry.lineNo}: expected "<package>:" in ${section}, got "${entry.text.trim()}"`,
889
+ );
890
+ }
891
+ dependencies.push({
892
+ section,
893
+ name: m[1] ?? m[2] ?? m[3]!,
894
+ version: m[4] !== undefined && m[4].length > 0 ? stripYamlQuotes(m[4]) : undefined,
895
+ });
896
+ currentDep = { index: dependencies.length - 1, version: dependencies.at(-1)?.version };
897
+ } else if (currentDep !== null) {
898
+ // nested detail; only a direct `version:` refines the dependency fact
899
+ const m = YAML_KEY.exec(entry.text.trim());
900
+ const nestedKey = m !== null ? (m[1] ?? m[2] ?? m[3]!) : undefined;
901
+ if (
902
+ nestedKey === "version" &&
903
+ m?.[4] !== undefined &&
904
+ dependencies[currentDep.index]!.version === undefined
905
+ ) {
906
+ dependencies[currentDep.index]!.version = stripYamlQuotes(m[4]);
907
+ }
908
+ }
909
+ }
910
+ }
911
+ return { name, version, dependencies };
912
+ }