jg-plans 0.1.0 → 0.2.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.
- package/CHANGELOG.md +20 -0
- package/README.md +8 -8
- package/api/README.md +75 -12
- package/cli/README.md +24 -6
- package/dist/cli/arguments.js +18 -3
- package/dist/cli/commands.js +76 -17
- package/dist/cli/types.d.ts +3 -1
- package/dist/documents/fingerprints.d.ts +19 -0
- package/dist/documents/fingerprints.js +56 -0
- package/dist/documents/index.d.ts +2 -1
- package/dist/documents/index.js +1 -0
- package/dist/documents/milestones.js +44 -33
- package/dist/documents/plan.js +33 -68
- package/dist/documents/types.d.ts +22 -0
- package/dist/documents/utilities.d.ts +16 -2
- package/dist/documents/utilities.js +105 -4
- package/dist/edits/index.d.ts +2 -0
- package/dist/edits/index.js +2 -1
- package/dist/edits/preparations.d.ts +30 -0
- package/dist/edits/preparations.js +159 -0
- package/dist/edits/types.d.ts +14 -0
- package/dist/edits/utilities.d.ts +1 -0
- package/dist/edits/utilities.js +9 -2
- package/dist/errors/constants.d.ts +3 -1
- package/dist/errors/constants.js +4 -0
- package/dist/files/documents.d.ts +46 -0
- package/dist/files/documents.js +176 -0
- package/dist/files/index.d.ts +3 -1
- package/dist/files/index.js +2 -0
- package/dist/files/locks.d.ts +16 -0
- package/dist/files/locks.js +127 -0
- package/dist/files/mutations.d.ts +7 -3
- package/dist/files/mutations.js +32 -83
- package/dist/files/types.d.ts +19 -5
- package/dist/files/utilities.d.ts +4 -4
- package/dist/files/utilities.js +24 -7
- package/dist/index.d.ts +10 -8
- package/dist/index.js +5 -4
- package/dist/protocol/index.d.ts +4 -4
- package/dist/protocol/index.js +2 -2
- package/dist/protocol/types.d.ts +63 -0
- package/dist/protocol/types.js +53 -1
- package/docs/getting-started.md +4 -4
- package/docs/integrations.md +14 -14
- package/errors/README.md +6 -4
- package/examples/library.mjs +34 -2
- package/package.json +4 -3
- package/protocol/0.1/README.md +8 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { checkPlan } from './check.ts';
|
|
2
2
|
export { parseMilestones } from './milestones.ts';
|
|
3
3
|
export { parsePlan } from './plan.ts';
|
|
4
|
+
export { fingerprintPlan } from './fingerprints.ts';
|
|
4
5
|
export { sourceLines } from './utilities.ts';
|
|
5
|
-
export type { ICheckResult, IFrontmatter, IMarker, IMilestone, IOverviewRow, IParsedMilestones, IParsedPlan, } from './types.ts';
|
|
6
|
+
export type { ICheckResult, IFrontmatter, IMarker, IMilestone, IOverviewRow, IParsedMilestones, IParsedPlan, ISourceSpan, IHeadingSection, IPlanFingerprint, } from './types.ts';
|
package/dist/documents/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { checkPlan } from "./check.js";
|
|
2
2
|
export { parseMilestones } from "./milestones.js";
|
|
3
3
|
export { parsePlan } from "./plan.js";
|
|
4
|
+
export { fingerprintPlan } from "./fingerprints.js";
|
|
4
5
|
// Package-private lexical support; omitted from the package's public root exports.
|
|
5
6
|
export { sourceLines } from "./utilities.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { planErrors } from "../errors/index.js";
|
|
3
|
-
import { diagnostic,
|
|
3
|
+
import { diagnostic, headingSections, readFrontmatter, sourceLocation } from "./utilities.js";
|
|
4
4
|
const markerFrom = (line, prefix) => {
|
|
5
5
|
const leading = line.text.length - line.text.trimStart().length;
|
|
6
6
|
if (!line.text.slice(leading).startsWith(prefix))
|
|
@@ -22,48 +22,51 @@ const cells = (line) => line
|
|
|
22
22
|
.replace(/^\||\|$/g, '')
|
|
23
23
|
.split('|')
|
|
24
24
|
.map((cell) => cell.trim());
|
|
25
|
-
const readOverview = (lines) => {
|
|
25
|
+
const readOverview = (lines, sections) => {
|
|
26
26
|
const rows = [];
|
|
27
|
+
const tables = [];
|
|
27
28
|
let present = false;
|
|
28
29
|
let inOverview = false;
|
|
29
|
-
|
|
30
|
+
const headings = new Map(sections.map((section) => [section.heading.start, section.title]));
|
|
30
31
|
for (let index = 0; index < lines.length; index++) {
|
|
31
32
|
const line = lines[index];
|
|
32
33
|
if (line.fenced)
|
|
33
34
|
continue;
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
inOverview =
|
|
37
|
-
inTable = false;
|
|
38
|
-
}
|
|
35
|
+
const start = line.start + (line.start === 0 && line.text.startsWith('\uFEFF') ? 1 : 0);
|
|
36
|
+
if (headings.has(start))
|
|
37
|
+
inOverview = headings.get(start) === 'Overview';
|
|
39
38
|
if (!inOverview)
|
|
40
39
|
continue;
|
|
41
40
|
const columns = cells(line.text);
|
|
42
41
|
if (columns.join('|') === 'Milestone|Name|Status') {
|
|
43
42
|
const separator = cells(lines[index + 1]?.text ?? '');
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
if (!lines[index + 1]?.fenced &&
|
|
44
|
+
separator.length === 3 &&
|
|
45
|
+
separator.every((cell) => /^:?-{3,}:?$/.test(cell))) {
|
|
46
46
|
present = true;
|
|
47
|
-
|
|
47
|
+
const start = line.start;
|
|
48
|
+
let end = lines[++index].fullEnd;
|
|
49
|
+
while (index + 1 < lines.length) {
|
|
50
|
+
const row = lines[index + 1];
|
|
51
|
+
const values = cells(row.text);
|
|
52
|
+
if (row.fenced || values.length !== 3 || !/^\d+$/.test(values[0]))
|
|
53
|
+
break;
|
|
54
|
+
index++;
|
|
55
|
+
end = row.fullEnd;
|
|
56
|
+
const ordinal = Number(values[0]);
|
|
57
|
+
rows.push({
|
|
58
|
+
ordinal: Number.isSafeInteger(ordinal) && ordinal > 0 ? ordinal : null,
|
|
59
|
+
ordinalText: values[0],
|
|
60
|
+
name: values[1],
|
|
61
|
+
status: values[2],
|
|
62
|
+
location: { offset: row.start, line: row.number, column: 1 },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
tables.push({ start, end });
|
|
48
66
|
}
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
if (!inTable)
|
|
52
|
-
continue;
|
|
53
|
-
if (columns.length !== 3 || !/^\d+$/.test(columns[0])) {
|
|
54
|
-
inTable = false;
|
|
55
|
-
continue;
|
|
56
67
|
}
|
|
57
|
-
const ordinal = Number(columns[0]);
|
|
58
|
-
rows.push({
|
|
59
|
-
ordinal: Number.isSafeInteger(ordinal) && ordinal > 0 ? ordinal : null,
|
|
60
|
-
ordinalText: columns[0],
|
|
61
|
-
name: columns[1],
|
|
62
|
-
status: columns[2],
|
|
63
|
-
location: { offset: line.start, line: line.number, column: 1 },
|
|
64
|
-
});
|
|
65
68
|
}
|
|
66
|
-
return { present, rows };
|
|
69
|
+
return { present, rows, tables };
|
|
67
70
|
};
|
|
68
71
|
/**
|
|
69
72
|
* Observe numbered milestones and their markers, ignoring fenced examples.
|
|
@@ -76,23 +79,30 @@ const readOverview = (lines) => {
|
|
|
76
79
|
export const parseMilestones = (source) => {
|
|
77
80
|
if (!z.string().safeParse(source).success)
|
|
78
81
|
throw planErrors.create('invalidInput');
|
|
79
|
-
const
|
|
80
|
-
const
|
|
82
|
+
const observed = readFrontmatter(source, 'milestones');
|
|
83
|
+
const { lines } = observed;
|
|
84
|
+
const sections = headingSections(source, lines);
|
|
85
|
+
const overview = readOverview(lines, sections);
|
|
81
86
|
const result = {
|
|
82
87
|
source,
|
|
88
|
+
body: observed.body,
|
|
89
|
+
frontmatter: observed.frontmatter,
|
|
90
|
+
rawMetadata: observed.rawMetadata,
|
|
91
|
+
preamble: { start: 0, end: sections[0]?.heading.start ?? source.length },
|
|
92
|
+
sections,
|
|
93
|
+
overviewTables: overview.tables,
|
|
83
94
|
milestones: [],
|
|
84
95
|
overview: overview.rows,
|
|
85
96
|
overviewPresent: overview.present,
|
|
86
|
-
diagnostics:
|
|
97
|
+
diagnostics: observed.diagnostics,
|
|
87
98
|
};
|
|
88
|
-
const headings =
|
|
89
|
-
/^ {0,3}##[ \t]+/.test(line.start === 0 ? line.text.replace(/^\uFEFF/, '') : line.text));
|
|
99
|
+
const headings = sections.map((section) => lines[sourceLocation(lines, section.heading.start).line - 1]);
|
|
90
100
|
const ordinals = new Set();
|
|
91
101
|
const ids = new Set();
|
|
92
102
|
let previous = 0;
|
|
93
103
|
for (let index = 0; index < headings.length; index++) {
|
|
94
104
|
const heading = headings[index];
|
|
95
|
-
const match = /^
|
|
105
|
+
const match = /^Milestone (-?\d+):[ \t]*(.*)$/s.exec(sections[index].title);
|
|
96
106
|
if (!match)
|
|
97
107
|
continue;
|
|
98
108
|
const end = headings[index + 1]?.start ?? source.length;
|
|
@@ -114,6 +124,7 @@ export const parseMilestones = (source) => {
|
|
|
114
124
|
status: statuses[0]?.value ?? null,
|
|
115
125
|
id: identities[0]?.value ?? null,
|
|
116
126
|
location,
|
|
127
|
+
headingEnd: heading.fullEnd,
|
|
117
128
|
end,
|
|
118
129
|
body: source.slice(heading.fullEnd, end),
|
|
119
130
|
statusMarkers: statuses,
|
package/dist/documents/plan.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { isMap, isNode, isScalar
|
|
1
|
+
import { isMap, isNode, isScalar } from 'yaml';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { planErrors } from "../errors/index.js";
|
|
4
4
|
import { PlanMetadataSchema } from "../protocol/index.js";
|
|
5
|
-
import { diagnostic,
|
|
5
|
+
import { diagnostic, headingSections, readFrontmatter, sourceLocation } from "./utilities.js";
|
|
6
6
|
/**
|
|
7
7
|
* Observe a plan without requiring valid metadata. Source and body remain unchanged.
|
|
8
8
|
* Unsupported YAML expansion retains source and a diagnostic, not a fabricated value.
|
|
@@ -14,80 +14,45 @@ import { diagnostic, sourceLines, sourceLocation } from "./utilities.js";
|
|
|
14
14
|
export const parsePlan = (source) => {
|
|
15
15
|
if (!z.string().safeParse(source).success)
|
|
16
16
|
throw planErrors.create('invalidInput');
|
|
17
|
+
const observed = readFrontmatter(source, 'plan');
|
|
18
|
+
const sections = headingSections(source, observed.lines);
|
|
17
19
|
const result = {
|
|
18
20
|
source,
|
|
19
|
-
body:
|
|
20
|
-
frontmatter:
|
|
21
|
-
rawMetadata:
|
|
21
|
+
body: observed.body,
|
|
22
|
+
frontmatter: observed.frontmatter,
|
|
23
|
+
rawMetadata: observed.rawMetadata,
|
|
22
24
|
metadata: {},
|
|
23
|
-
|
|
25
|
+
preamble: { start: 0, end: sections[0]?.heading.start ?? source.length },
|
|
26
|
+
sections,
|
|
27
|
+
diagnostics: observed.diagnostics,
|
|
24
28
|
};
|
|
25
|
-
const lines =
|
|
26
|
-
|
|
27
|
-
const bom = source.startsWith('\uFEFF') ? 1 : 0;
|
|
28
|
-
if (!first || !/^---[ \t]*$/.test(first.text.slice(bom))) {
|
|
29
|
+
const { yaml: document, frontmatter, lines } = observed;
|
|
30
|
+
if (!frontmatter)
|
|
29
31
|
result.diagnostics.push(diagnostic('plan', 'MISSING_FRONTMATTER', 'Preferred plan metadata is absent.'));
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
for (const issue of document.errors)
|
|
47
|
-
result.diagnostics.push(diagnostic('plan', 'INVALID_YAML', `YAML could not be fully parsed (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0])));
|
|
48
|
-
for (const issue of document.warnings)
|
|
49
|
-
result.diagnostics.push(diagnostic('plan', 'YAML_WARNING', `YAML reader warning (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0]), 'warning'));
|
|
50
|
-
try {
|
|
51
|
-
const raw = document.toJS({ maxAliasCount: 100 });
|
|
52
|
-
// JSON automation cannot represent recursive aliases, BigInt or nonfinite numbers.
|
|
53
|
-
// Keep their original YAML and report unavailable expansion instead of changing values.
|
|
54
|
-
JSON.stringify(raw, (_key, value) => {
|
|
55
|
-
if (typeof value === 'number' &&
|
|
56
|
-
(!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
|
|
57
|
-
throw planErrors.create('invalidInput');
|
|
58
|
-
return value;
|
|
59
|
-
});
|
|
60
|
-
result.rawMetadata = raw;
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
result.diagnostics.push(diagnostic('plan', 'YAML_VALUE_UNAVAILABLE', 'Some YAML values cannot be expanded into JSON-safe observations; the original source is retained.', sourceLocation(lines, first.fullEnd), 'warning'));
|
|
64
|
-
}
|
|
65
|
-
if (!isMap(document.contents)) {
|
|
66
|
-
result.diagnostics.push(diagnostic('plan', 'METADATA_NOT_MAPPING', 'Preferred frontmatter is a mapping of metadata fields.', sourceLocation(lines, first.fullEnd)));
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
for (const key of Object.keys(PlanMetadataSchema.shape)) {
|
|
70
|
-
const node = document.get(key, true);
|
|
71
|
-
if (node === undefined)
|
|
72
|
-
continue;
|
|
73
|
-
const raw = result.rawMetadata;
|
|
74
|
-
const value = raw !== null && typeof raw === 'object' && Object.hasOwn(raw, key)
|
|
75
|
-
? Reflect.get(raw, key)
|
|
76
|
-
: isScalar(node)
|
|
77
|
-
? node.value
|
|
78
|
-
: node;
|
|
79
|
-
const parsed = PlanMetadataSchema.shape[key].safeParse(value);
|
|
80
|
-
if (parsed.success)
|
|
81
|
-
Object.assign(result.metadata, { [key]: parsed.data });
|
|
82
|
-
else
|
|
83
|
-
result.diagnostics.push(diagnostic('plan', 'INVALID_METADATA_FIELD', `Metadata field '${key}' has an incompatible value.`, sourceLocation(lines, first.fullEnd + (isNode(node) ? (node.range?.[0] ?? 0) : 0))));
|
|
84
|
-
}
|
|
32
|
+
if (document && frontmatter && isMap(document.contents)) {
|
|
33
|
+
for (const key of Object.keys(PlanMetadataSchema.shape)) {
|
|
34
|
+
const node = document.get(key, true);
|
|
35
|
+
if (node === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
const raw = result.rawMetadata;
|
|
38
|
+
const value = raw !== null && typeof raw === 'object' && Object.hasOwn(raw, key)
|
|
39
|
+
? Reflect.get(raw, key)
|
|
40
|
+
: isScalar(node)
|
|
41
|
+
? node.value
|
|
42
|
+
: node;
|
|
43
|
+
const parsed = PlanMetadataSchema.shape[key].safeParse(value);
|
|
44
|
+
if (parsed.success)
|
|
45
|
+
Object.assign(result.metadata, { [key]: parsed.data });
|
|
46
|
+
else
|
|
47
|
+
result.diagnostics.push(diagnostic('plan', 'INVALID_METADATA_FIELD', `Metadata field '${key}' has an incompatible value.`, sourceLocation(lines, frontmatter.contentStart + (isNode(node) ? (node.range?.[0] ?? 0) : 0))));
|
|
85
48
|
}
|
|
86
49
|
}
|
|
50
|
+
const invalid = new Set(result.diagnostics
|
|
51
|
+
.filter((item) => item.code === 'INVALID_METADATA_FIELD')
|
|
52
|
+
.map((item) => item.message));
|
|
87
53
|
for (const key of Object.keys(PlanMetadataSchema.shape)) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!(key in result.metadata) && !isObserved)
|
|
54
|
+
if (!(key in result.metadata) &&
|
|
55
|
+
!invalid.has(`Metadata field '${key}' has an incompatible value.`))
|
|
91
56
|
result.diagnostics.push(diagnostic('plan', 'MISSING_METADATA_FIELD', `Preferred metadata field '${key}' is absent.`));
|
|
92
57
|
}
|
|
93
58
|
return result;
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { IDiagnostic, IPlanMetadata, ISourceLocation } from '../protocol/index.ts';
|
|
2
|
+
/** Original-source UTF-16 offsets, with an exclusive end. */
|
|
3
|
+
export type ISourceSpan = {
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
};
|
|
7
|
+
/** One unfenced level-two heading and all text up to the next such heading. */
|
|
8
|
+
export type IHeadingSection = {
|
|
9
|
+
title: string;
|
|
10
|
+
heading: ISourceSpan;
|
|
11
|
+
body: ISourceSpan;
|
|
12
|
+
};
|
|
2
13
|
/** Read models retain original text and partial observations; diagnostics carry ambiguity. */
|
|
3
14
|
export type IFrontmatter = {
|
|
4
15
|
start: number;
|
|
@@ -13,6 +24,8 @@ export type IParsedPlan = {
|
|
|
13
24
|
frontmatter: IFrontmatter | null;
|
|
14
25
|
rawMetadata: unknown;
|
|
15
26
|
metadata: Partial<IPlanMetadata>;
|
|
27
|
+
preamble: ISourceSpan;
|
|
28
|
+
sections: IHeadingSection[];
|
|
16
29
|
diagnostics: IDiagnostic[];
|
|
17
30
|
};
|
|
18
31
|
export type IMarker = {
|
|
@@ -30,6 +43,7 @@ export type IMilestone = {
|
|
|
30
43
|
status: string | null;
|
|
31
44
|
id: string | null;
|
|
32
45
|
location: ISourceLocation;
|
|
46
|
+
headingEnd: number;
|
|
33
47
|
end: number;
|
|
34
48
|
body: string;
|
|
35
49
|
statusMarkers: IMarker[];
|
|
@@ -44,6 +58,12 @@ export type IOverviewRow = {
|
|
|
44
58
|
};
|
|
45
59
|
export type IParsedMilestones = {
|
|
46
60
|
source: string;
|
|
61
|
+
body: string;
|
|
62
|
+
frontmatter: IFrontmatter | null;
|
|
63
|
+
rawMetadata: unknown;
|
|
64
|
+
preamble: ISourceSpan;
|
|
65
|
+
sections: IHeadingSection[];
|
|
66
|
+
overviewTables: ISourceSpan[];
|
|
47
67
|
milestones: IMilestone[];
|
|
48
68
|
overview: IOverviewRow[];
|
|
49
69
|
overviewPresent: boolean;
|
|
@@ -53,6 +73,8 @@ export type ICheckResult = {
|
|
|
53
73
|
conforms: boolean;
|
|
54
74
|
diagnostics: IDiagnostic[];
|
|
55
75
|
};
|
|
76
|
+
/** A structural observation; consumers decide exclusions and authorization policy. */
|
|
77
|
+
export type { IPlanFingerprint } from '../protocol/index.ts';
|
|
56
78
|
export type ISourceLine = {
|
|
57
79
|
text: string;
|
|
58
80
|
start: number;
|
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
1
2
|
import type { IDiagnostic, ISourceLocation } from '../protocol/index.ts';
|
|
2
|
-
import type { ISourceLine } from './types.ts';
|
|
3
|
+
import type { IFrontmatter, IHeadingSection, ISourceLine } from './types.ts';
|
|
3
4
|
/** Preserve original offsets and line endings. Fence recognition derives from public jg-agent-hq. */
|
|
4
|
-
export declare const sourceLines: (source: string) => ISourceLine[];
|
|
5
|
+
export declare const sourceLines: (source: string, frontmatterEnd?: number) => ISourceLine[];
|
|
5
6
|
/** Binary lookup avoids rescanning the source for each diagnostic. */
|
|
6
7
|
export declare const sourceLocation: (lines: ISourceLine[], offset: number) => ISourceLocation;
|
|
7
8
|
export declare const diagnostic: (document: IDiagnostic["document"], code: string, message: string, location?: ISourceLocation, severity?: IDiagnostic["severity"]) => IDiagnostic;
|
|
9
|
+
type IFrontmatterRead = {
|
|
10
|
+
body: string;
|
|
11
|
+
frontmatter: IFrontmatter | null;
|
|
12
|
+
rawMetadata: unknown;
|
|
13
|
+
yaml: ReturnType<typeof parseDocument> | null;
|
|
14
|
+
lines: ISourceLine[];
|
|
15
|
+
diagnostics: IDiagnostic[];
|
|
16
|
+
};
|
|
17
|
+
/** Shared framing/YAML reader; preferred plan fields are validated by the plan parser alone. */
|
|
18
|
+
export declare const readFrontmatter: (source: string, name: IDiagnostic["document"]) => IFrontmatterRead;
|
|
19
|
+
/** Complete, ordered section spans; the prefix before the first heading is the preamble. */
|
|
20
|
+
export declare const headingSections: (source: string, lines: ISourceLine[]) => IHeadingSection[];
|
|
21
|
+
export {};
|
|
@@ -1,21 +1,27 @@
|
|
|
1
|
+
import { stringifyJsonDeterministically } from 'jg-utilities';
|
|
2
|
+
import { isMap, isScalar, parseDocument, visit } from 'yaml';
|
|
3
|
+
import { planErrors } from "../errors/index.js";
|
|
1
4
|
/** Preserve original offsets and line endings. Fence recognition derives from public jg-agent-hq. */
|
|
2
|
-
export const sourceLines = (source) => {
|
|
5
|
+
export const sourceLines = (source, frontmatterEnd = 0) => {
|
|
3
6
|
const lines = [];
|
|
4
7
|
let fence = null;
|
|
5
8
|
for (const match of source.matchAll(/([^\r\n]*)(\r\n|\n|\r|$)/g)) {
|
|
6
9
|
if (!match[0])
|
|
7
10
|
continue;
|
|
8
11
|
const text = match[1];
|
|
9
|
-
const marker = /^ {0,3}(`{3,}|~{3,})(.*)
|
|
12
|
+
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/s.exec(match.index === 0 ? text.replace(/^\uFEFF/, '') : text);
|
|
10
13
|
const line = {
|
|
11
14
|
text,
|
|
12
15
|
start: match.index,
|
|
13
16
|
end: match.index + text.length,
|
|
14
17
|
fullEnd: match.index + match[0].length,
|
|
15
18
|
number: lines.length + 1,
|
|
16
|
-
fenced: fence !== null,
|
|
19
|
+
fenced: match.index < frontmatterEnd || fence !== null,
|
|
17
20
|
};
|
|
18
|
-
if (
|
|
21
|
+
if (match.index < frontmatterEnd) {
|
|
22
|
+
// YAML text never opens a Markdown fence that could hide later body sections.
|
|
23
|
+
}
|
|
24
|
+
else if (fence) {
|
|
19
25
|
if (marker &&
|
|
20
26
|
marker[1][0] === fence.character &&
|
|
21
27
|
marker[1].length >= fence.length &&
|
|
@@ -51,3 +57,98 @@ export const diagnostic = (document, code, message, location, severity = 'error'
|
|
|
51
57
|
severity,
|
|
52
58
|
...(location ? { location } : {}),
|
|
53
59
|
});
|
|
60
|
+
/** Shared framing/YAML reader; preferred plan fields are validated by the plan parser alone. */
|
|
61
|
+
export const readFrontmatter = (source, name) => {
|
|
62
|
+
const lines = sourceLines(source);
|
|
63
|
+
const result = {
|
|
64
|
+
body: source,
|
|
65
|
+
frontmatter: null,
|
|
66
|
+
rawMetadata: null,
|
|
67
|
+
yaml: null,
|
|
68
|
+
lines,
|
|
69
|
+
diagnostics: [],
|
|
70
|
+
};
|
|
71
|
+
const first = lines[0];
|
|
72
|
+
const bom = source.startsWith('\uFEFF') ? 1 : 0;
|
|
73
|
+
if (!first || !/^---[ \t]*$/.test(first.text.slice(bom)))
|
|
74
|
+
return result;
|
|
75
|
+
const closing = lines.slice(1).find((line) => /^---[ \t]*$/.test(line.text));
|
|
76
|
+
result.frontmatter = {
|
|
77
|
+
start: bom,
|
|
78
|
+
contentStart: first.fullEnd,
|
|
79
|
+
contentEnd: closing?.start ?? source.length,
|
|
80
|
+
end: closing?.fullEnd ?? source.length,
|
|
81
|
+
closed: Boolean(closing),
|
|
82
|
+
};
|
|
83
|
+
result.lines = sourceLines(source, result.frontmatter.end);
|
|
84
|
+
if (closing)
|
|
85
|
+
result.body = source.slice(closing.fullEnd);
|
|
86
|
+
else
|
|
87
|
+
result.diagnostics.push(diagnostic(name, 'UNTERMINATED_FRONTMATTER', 'The opening frontmatter has no closing delimiter.', sourceLocation(lines, bom)));
|
|
88
|
+
const document = parseDocument(source.slice(first.fullEnd, closing?.start ?? source.length), {
|
|
89
|
+
keepSourceTokens: true,
|
|
90
|
+
logLevel: 'silent',
|
|
91
|
+
});
|
|
92
|
+
result.yaml = document;
|
|
93
|
+
for (const issue of document.errors)
|
|
94
|
+
result.diagnostics.push(diagnostic(name, 'INVALID_YAML', `YAML could not be fully parsed (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0])));
|
|
95
|
+
for (const issue of document.warnings)
|
|
96
|
+
result.diagnostics.push(diagnostic(name, 'YAML_WARNING', `YAML reader warning (${issue.code}).`, sourceLocation(lines, first.fullEnd + issue.pos[0]), 'warning'));
|
|
97
|
+
try {
|
|
98
|
+
// YAML coerces non-string mapping keys during toJS; that would lose structural identity.
|
|
99
|
+
visit(document, {
|
|
100
|
+
Pair(_key, pair) {
|
|
101
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== 'string')
|
|
102
|
+
throw planErrors.create('invalidInput');
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const raw = document.toJS({ maxAliasCount: 100 });
|
|
106
|
+
JSON.stringify(raw, (_key, value) => {
|
|
107
|
+
if (typeof value === 'number' &&
|
|
108
|
+
(!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))
|
|
109
|
+
throw planErrors.create('invalidInput');
|
|
110
|
+
return value;
|
|
111
|
+
});
|
|
112
|
+
// Wrapping permits observable scalar/array YAML while rejecting lossy JSON expansion.
|
|
113
|
+
stringifyJsonDeterministically({ metadata: raw }, { strict: true });
|
|
114
|
+
result.rawMetadata = raw;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
result.diagnostics.push(diagnostic(name, 'YAML_VALUE_UNAVAILABLE', 'Some YAML values cannot be expanded into JSON-safe observations; the original source is retained.', sourceLocation(lines, first.fullEnd), 'warning'));
|
|
118
|
+
}
|
|
119
|
+
if (!isMap(document.contents))
|
|
120
|
+
result.diagnostics.push(diagnostic(name, 'METADATA_NOT_MAPPING', 'Preferred frontmatter is a mapping of metadata fields.', sourceLocation(lines, first.fullEnd)));
|
|
121
|
+
return result;
|
|
122
|
+
};
|
|
123
|
+
/** Complete, ordered section spans; the prefix before the first heading is the preamble. */
|
|
124
|
+
export const headingSections = (source, lines) => {
|
|
125
|
+
const headings = lines.flatMap((line) => {
|
|
126
|
+
if (line.fenced)
|
|
127
|
+
return [];
|
|
128
|
+
const bom = line.start === 0 && line.text.startsWith('\uFEFF') ? 1 : 0;
|
|
129
|
+
const text = line.text.slice(bom);
|
|
130
|
+
const prefix = /^ {0,3}##[ \t]+/.exec(text);
|
|
131
|
+
if (!prefix)
|
|
132
|
+
return [];
|
|
133
|
+
// Linear scans avoid backtracking over long whitespace runs before a nonclosing suffix.
|
|
134
|
+
const start = prefix[0].length;
|
|
135
|
+
let end = text.length;
|
|
136
|
+
while (end > start && (text[end - 1] === ' ' || text[end - 1] === '\t'))
|
|
137
|
+
end--;
|
|
138
|
+
let hashes = end;
|
|
139
|
+
while (hashes > start && text[hashes - 1] === '#')
|
|
140
|
+
hashes--;
|
|
141
|
+
if (hashes < end &&
|
|
142
|
+
(hashes === start || text[hashes - 1] === ' ' || text[hashes - 1] === '\t')) {
|
|
143
|
+
end = hashes;
|
|
144
|
+
while (end > start && (text[end - 1] === ' ' || text[end - 1] === '\t'))
|
|
145
|
+
end--;
|
|
146
|
+
}
|
|
147
|
+
return [{ line, bom, title: text.slice(start, end) }];
|
|
148
|
+
});
|
|
149
|
+
return headings.map(({ line, bom, title }, index) => ({
|
|
150
|
+
title,
|
|
151
|
+
heading: { start: line.start + bom, end: line.fullEnd },
|
|
152
|
+
body: { start: line.fullEnd, end: headings[index + 1]?.line.start ?? source.length },
|
|
153
|
+
}));
|
|
154
|
+
};
|
package/dist/edits/index.d.ts
CHANGED
package/dist/edits/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Byte-editing primitives stay package-private; consumers can prepare edits without filesystem I/O.
|
|
2
2
|
export { applyEdits, decodeSource, encodeSource, milestoneEdits, planEdits } from "./utilities.js";
|
|
3
|
+
export { preparePlanUpdate, preparePlanDocumentWrite } from "./preparations.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type IPlanDocumentWrite, type IPlanUpdate } from '../protocol/index.ts';
|
|
2
|
+
import type { IPreparedPlanEdit } from './types.ts';
|
|
3
|
+
/** Package-private expectation guard, also used on original read bytes by filesystem adapters. */
|
|
4
|
+
export declare const assertSourceExpectation: (source: string | Uint8Array | null, expected: string | null | undefined) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Prepare one guarded metadata/marker patch entirely in memory, without policy updates or I/O.
|
|
7
|
+
* @param source Original selected document text.
|
|
8
|
+
* @param patch Explicit narrow patch and optional observed exact-byte SHA-256.
|
|
9
|
+
* @returns Prepared source, digest, diagnostics, and whether bytes would change.
|
|
10
|
+
* @throws
|
|
11
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
12
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
13
|
+
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
14
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
15
|
+
*/
|
|
16
|
+
export declare const preparePlanUpdate: (source: string, patch: IPlanUpdate) => IPreparedPlanEdit;
|
|
17
|
+
/**
|
|
18
|
+
* Prepare guarded body/metadata replacement or explicit milestone source/ID assignment in memory.
|
|
19
|
+
* A null source is accepted only for explicit absent milestone creation. No timestamps, revisions,
|
|
20
|
+
* identities, or completion are inferred; missing IDs are assigned only when requested.
|
|
21
|
+
* @param source Original selected document text, or null for absent milestones.
|
|
22
|
+
* @param input Explicit document replacement and required source expectation.
|
|
23
|
+
* @returns The exact source to persist once, its digest, diagnostics, and generated IDs.
|
|
24
|
+
* @throws
|
|
25
|
+
* - JG_PLANS_INVALID_INPUT: Invalid jg-plans input
|
|
26
|
+
* - JG_PLANS_UNSUPPORTED_ENCODING: Document text is not valid UTF-8
|
|
27
|
+
* - JG_PLANS_UNSAFE_EDIT: The requested edit is unsupported or ambiguous
|
|
28
|
+
* - JG_PLANS_SOURCE_CHANGED: The document changed before replacement
|
|
29
|
+
*/
|
|
30
|
+
export declare const preparePlanDocumentWrite: (source: string | null, input: IPlanDocumentWrite) => IPreparedPlanEdit;
|