gcf-aif-ext 0.7.7 → 0.7.8
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/README.md +8 -0
- package/extension.json +1 -1
- package/lib/audit-core.js +4 -3
- package/lib/config-set.js +4 -0
- package/lib/context.js +3 -2
- package/lib/design-check.js +3 -2
- package/lib/gcf-paths.js +1 -0
- package/lib/index-gen.js +4 -2
- package/lib/question-fields.js +70 -0
- package/lib/questions.js +26 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,14 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
|
|
|
50
50
|
|
|
51
51
|
On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
|
|
52
52
|
|
|
53
|
+
### 0.7.8
|
|
54
|
+
|
|
55
|
+
- An open question's field written as a YAML block (`assumed: |` or `>`, and the lines under it) reads as its text: before, `gcf board --json` gave the block sign `|`. Every reader of `gcf/open-questions.md` (the board, `design check`, the audit, `context`, the index) reads the fields the same way:
|
|
56
|
+
- a field is a line at the record's field indent, so a block line such as `answer: x` is text: before, it made a D1 read as answered;
|
|
57
|
+
- a blank line inside a block no longer ends the record: before, the fields after it were lost;
|
|
58
|
+
- `gcf question answer` replaces an old answer written as a block whole: before, it replaced only the block's first line;
|
|
59
|
+
- an empty `answer:` is no answer: `design check` now holds the D1's units, as the board already showed it unanswered.
|
|
60
|
+
|
|
53
61
|
### 0.7.7
|
|
54
62
|
|
|
55
63
|
- `ai-factory gcf config [--json]` lists the human's settings of `gcf/config.yml` with what each means; `gcf config set <key> --value=<v>` sets one, checked by the rules a read applies, the comments kept, one `gcf(config)` commit. An empty number goes back to its default. `gcf board --json` carries them as `settings`.
|
package/extension.json
CHANGED
package/lib/audit-core.js
CHANGED
|
@@ -11,6 +11,7 @@ import { STORED } from './transitions.js';
|
|
|
11
11
|
import { openDepends } from './work-ops.js';
|
|
12
12
|
import { runHolder } from './main-lock.js';
|
|
13
13
|
import { sessionActive } from './session-mark.js';
|
|
14
|
+
import { fieldOf } from './question-fields.js';
|
|
14
15
|
|
|
15
16
|
function git(args, cwd) {
|
|
16
17
|
// A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
|
|
@@ -276,7 +277,7 @@ const questionsText = async (root) => {
|
|
|
276
277
|
return existsSync(file) ? readFile(file, 'utf8') : '';
|
|
277
278
|
};
|
|
278
279
|
|
|
279
|
-
const D2_UNTIL_RE =
|
|
280
|
+
const D2_UNTIL_RE = /^(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))/;
|
|
280
281
|
|
|
281
282
|
/**
|
|
282
283
|
* Until annotations across work items and systems docs, and the `until:` of each D2 record in
|
|
@@ -296,8 +297,8 @@ function untilRecords(items, systems, fired, questions) {
|
|
|
296
297
|
|
|
297
298
|
for (const rec of questions.replace(/\r\n/g, '\n').split(/\n(?=- id:)/)) {
|
|
298
299
|
const id = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
299
|
-
const trigger = rec
|
|
300
|
-
if (!id || !trigger ||
|
|
300
|
+
const trigger = fieldOf(rec, 'until')?.match(D2_UNTIL_RE)?.[1];
|
|
301
|
+
if (!id || !trigger || !/^D2\b/.test(fieldOf(rec, 'class') ?? '')) continue;
|
|
301
302
|
until.push({
|
|
302
303
|
file: 'gcf/open-questions.md',
|
|
303
304
|
id,
|
package/lib/config-set.js
CHANGED
|
@@ -99,11 +99,13 @@ function valueOf(setting, text) {
|
|
|
99
99
|
|
|
100
100
|
return t;
|
|
101
101
|
}
|
|
102
|
+
|
|
102
103
|
if (setting.kind === 'mode') {
|
|
103
104
|
if (!RUN_MODES.includes(t)) throw new Error(`run_mode must be ${RUN_MODES.join(' | ')}`);
|
|
104
105
|
|
|
105
106
|
return t;
|
|
106
107
|
}
|
|
108
|
+
|
|
107
109
|
if (!t) return null;
|
|
108
110
|
// Plain decimals only: Number() would also take 0x10, 1e3 and 0b1.
|
|
109
111
|
if (!/^\d+(\.\d+)?$/.test(t)) throw new Error(`${setting.key} must be a number, got ${t}`);
|
|
@@ -144,6 +146,7 @@ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
|
|
|
144
146
|
throw new Error(`gcf/config.yml: ${keys[0]} must map its keys`);
|
|
145
147
|
if (!YAML.isMap(parent)) doc.set(keys[0], doc.createNode({}));
|
|
146
148
|
}
|
|
149
|
+
|
|
147
150
|
if (value === null) doc.deleteIn(keys);
|
|
148
151
|
else doc.setIn(keys, value);
|
|
149
152
|
raw = doc.toJS();
|
|
@@ -154,6 +157,7 @@ export async function setConfig(projectDir, key, text, { commit = true } = {}) {
|
|
|
154
157
|
unchanged = true;
|
|
155
158
|
throw NO_CHANGE;
|
|
156
159
|
}
|
|
160
|
+
|
|
157
161
|
await atomicWrite(file, out);
|
|
158
162
|
},
|
|
159
163
|
commit,
|
package/lib/context.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { listItems, listSystems, readConfig, tokensEst } from './gcf-paths.js';
|
|
5
|
+
import { fieldOf } from './question-fields.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Readset assembly — the whole command is deterministic (GCF-PRD §9.1, §10.1):
|
|
@@ -94,9 +95,9 @@ async function d2Assumptions(root, systems) {
|
|
|
94
95
|
const records = (await readFile(oqFile, 'utf8')).split(/\n(?=- id:)/);
|
|
95
96
|
|
|
96
97
|
return records
|
|
97
|
-
.filter((rec) => /^\s*-?\s*id:/m.test(rec) &&
|
|
98
|
+
.filter((rec) => /^\s*-?\s*id:/m.test(rec) && /^D2\b/.test(fieldOf(rec, 'class') ?? ''))
|
|
98
99
|
.filter((rec) => {
|
|
99
|
-
const sys = rec
|
|
100
|
+
const sys = fieldOf(rec, 'sys') ?? '';
|
|
100
101
|
|
|
101
102
|
return systems.some((s) => sys.includes(s));
|
|
102
103
|
})
|
package/lib/design-check.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { buildLedger } from './ledger.js';
|
|
20
20
|
import { sourceBodies } from './anchors.js';
|
|
21
21
|
import { collapseLines } from './verdict-fs.js';
|
|
22
|
+
import { fieldOf } from './question-fields.js';
|
|
22
23
|
|
|
23
24
|
// Units a number may carry. A project adds its own in the `units:` block of glossary.md.
|
|
24
25
|
// Bare `px` is absent on purpose: its size depends on the screen density.
|
|
@@ -122,7 +123,7 @@ export const recordsIn = (text, key) =>
|
|
|
122
123
|
readRecords(itemsIn(text, key)).records.map((r) => r.values);
|
|
123
124
|
|
|
124
125
|
/** A D1 record nobody answered yet: only such a question holds its units. */
|
|
125
|
-
const blocksUnits = (rec) =>
|
|
126
|
+
const blocksUnits = (rec) => /^D1\b/.test(fieldOf(rec, 'class') ?? '') && !fieldOf(rec, 'answer');
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
129
|
* open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. A
|
|
@@ -136,7 +137,7 @@ export function questionBlocks(text) {
|
|
|
136
137
|
const qids = new Set([...src.matchAll(/^\s*- id:\s*(\S+)/gm)].map((m) => m[1]));
|
|
137
138
|
for (const rec of src.split(/\n(?=- id:)/)) {
|
|
138
139
|
const qid = rec.match(/^- id:\s*(\S+)/)?.[1];
|
|
139
|
-
const ids = listOf(rec
|
|
140
|
+
const ids = listOf(fieldOf(rec, 'blocks') ?? '');
|
|
140
141
|
if (!qid || ids === 'TBD' || !ids.length) continue;
|
|
141
142
|
refs.push({ qid, ids });
|
|
142
143
|
if (!blocksUnits(rec)) continue;
|
package/lib/gcf-paths.js
CHANGED
package/lib/index-gen.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { listItems, listSystems, firstBullet } from './gcf-paths.js';
|
|
5
5
|
import { parseLive } from './live.js';
|
|
6
6
|
import { atomicWrite } from './safe-path.js';
|
|
7
|
+
import { fieldOf } from './question-fields.js';
|
|
7
8
|
import { readDesign, slotValues, loadDocProfile, isTbd, UNIT_TYPES } from './design.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -46,8 +47,9 @@ export async function generate(root) {
|
|
|
46
47
|
|
|
47
48
|
const txt = await readIfExists(root, 'open-questions.md');
|
|
48
49
|
if (txt !== null) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
50
|
+
const classes = txt.split(/\n(?=- id:)/).map((rec) => fieldOf(rec, 'class') ?? '');
|
|
51
|
+
const d1 = classes.filter((c) => /^D1\b/.test(c)).length;
|
|
52
|
+
const d2 = classes.filter((c) => /^D2\b/.test(c)).length;
|
|
51
53
|
row(['questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`]);
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The fields of one record of gcf/open-questions.md (`- id:` and the indented lines under it).
|
|
2
|
+
// A field is a line at the record's field indent; a YAML block (`|`, `>`) is the deeper lines
|
|
3
|
+
// under its key, so a block line that reads like `answer: x` is text, not a field.
|
|
4
|
+
|
|
5
|
+
/** The indent of a record's fields: the indent of its first field line. */
|
|
6
|
+
export const indentOf = (record) => record.match(/^([ \t]+)\w+:/m)?.[1] ?? ' ';
|
|
7
|
+
|
|
8
|
+
/** The index after the lines under field line `i`: deeper ones, and blank ones between them. */
|
|
9
|
+
export function blockEnd(lines, i, indent) {
|
|
10
|
+
let end = i + 1;
|
|
11
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
12
|
+
if (!lines[j].trim()) continue;
|
|
13
|
+
if (!lines[j].startsWith(indent) || !/^[ \t]/.test(lines[j].slice(indent.length))) break;
|
|
14
|
+
end = j + 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return end;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `|`, `>-`, `|2`, `> # note`: a block's sign with its chomping, indentation and comment.
|
|
21
|
+
const BLOCK = /^([|>])[-+]?\d?[-+]?(\s+#.*)?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `>` block's lines folded: the lines of one paragraph join with a space, each blank line is a
|
|
25
|
+
* line break, and a more indented line keeps its breaks, the one before a blank line too.
|
|
26
|
+
*/
|
|
27
|
+
function folded(lines) {
|
|
28
|
+
let text = '';
|
|
29
|
+
let prev = null;
|
|
30
|
+
let blanks = 0;
|
|
31
|
+
for (const l of lines) {
|
|
32
|
+
if (!l) {
|
|
33
|
+
blanks += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const kind = /^[ \t]/.test(l) ? 'more' : 'text';
|
|
38
|
+
if (prev !== null) {
|
|
39
|
+
const both = prev === 'text' && kind === 'text';
|
|
40
|
+
if (blanks) text += '\n'.repeat(both ? blanks : blanks + 1);
|
|
41
|
+
else text += both ? ' ' : '\n';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
text += l;
|
|
45
|
+
prev = kind;
|
|
46
|
+
blanks = 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The value of field `k` in a record, or null. A block reads as its lines, the common indent
|
|
54
|
+
* taken off: `|` keeps the line breaks, `>` folds them (`folded`).
|
|
55
|
+
*/
|
|
56
|
+
export function fieldOf(record, k) {
|
|
57
|
+
const lines = record.split(/\r?\n/);
|
|
58
|
+
const indent = indentOf(record);
|
|
59
|
+
const i = lines.findIndex((l) => l.startsWith(`${indent}${k}:`));
|
|
60
|
+
if (i < 0) return null;
|
|
61
|
+
const value = lines[i].slice(indent.length + k.length + 1).trim();
|
|
62
|
+
const block = value.match(BLOCK)?.[1];
|
|
63
|
+
if (!block) return value || null;
|
|
64
|
+
const body = lines.slice(i + 1, blockEnd(lines, i, indent));
|
|
65
|
+
const cut = Math.min(...body.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length));
|
|
66
|
+
const dedented = body.map((l) => l.slice(cut).trimEnd());
|
|
67
|
+
const text = block === '|' ? dedented.join('\n') : folded(dedented);
|
|
68
|
+
|
|
69
|
+
return text.trim() || null;
|
|
70
|
+
}
|
package/lib/questions.js
CHANGED
|
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { atomicWrite } from './safe-path.js';
|
|
9
9
|
import { commitMove } from './unit-ops.js';
|
|
10
|
+
import { blockEnd, fieldOf, indentOf } from './question-fields.js';
|
|
10
11
|
|
|
11
12
|
// A D2 carries its assumption and the trigger that brings it back; `lens`, `sections` and `sys`
|
|
12
13
|
// say where a record came from: an import names its lens and the GDD sections.
|
|
@@ -27,19 +28,26 @@ const fileOf = (root) => path.join(root, 'open-questions.md');
|
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* The records of a file's text, each `{id, text, start}`: a `- id:` line and the indented lines
|
|
30
|
-
* under it.
|
|
31
|
-
* part of it.
|
|
31
|
+
* under it. An unindented line ends the record, and so does a blank one unless an indented line
|
|
32
|
+
* follows it (a blank line inside a block): a heading after the last record is not part of it.
|
|
32
33
|
*/
|
|
33
34
|
function recordsOf(text) {
|
|
34
35
|
const records = [];
|
|
35
36
|
let at = 0;
|
|
36
37
|
/** @type {{id: string, text: string, start: number} | null} */
|
|
37
38
|
let rec = null;
|
|
39
|
+
// Blank lines after a record's last line: its own when an indented line follows.
|
|
40
|
+
let blank = '';
|
|
38
41
|
for (const line of text.split(/(?<=\n)/)) {
|
|
39
42
|
const id = line.match(/^- id:\s*(\S+)/)?.[1];
|
|
40
43
|
if (id) records.push((rec = { id, text: line, start: at }));
|
|
41
|
-
else if (rec && /^[ \t]+\S/.test(line)) rec.text += line;
|
|
42
|
-
else rec
|
|
44
|
+
else if (rec && /^[ \t]+\S/.test(line)) rec.text += blank + line;
|
|
45
|
+
else if (rec && !line.trim()) {
|
|
46
|
+
blank += line;
|
|
47
|
+
at += line.length;
|
|
48
|
+
continue;
|
|
49
|
+
} else rec = null;
|
|
50
|
+
blank = '';
|
|
43
51
|
at += line.length;
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -56,23 +64,27 @@ export async function listQuestions(root) {
|
|
|
56
64
|
|
|
57
65
|
return recordsOf(await readFile(file, 'utf8')).map(({ id, text }) => ({
|
|
58
66
|
id,
|
|
59
|
-
...Object.fromEntries(
|
|
60
|
-
FIELDS.map((k) => [
|
|
61
|
-
k,
|
|
62
|
-
text.match(new RegExp(`^[ \\t]+${k}:[ \\t]*(.*?)\\s*$`, 'm'))?.[1] || null,
|
|
63
|
-
]),
|
|
64
|
-
),
|
|
67
|
+
...Object.fromEntries(FIELDS.map((k) => [k, fieldOf(text, k)])),
|
|
65
68
|
}));
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/** The record text with its `answer:` set: an old answer is replaced, a new one appended. */
|
|
69
72
|
function withAnswer(record, answer) {
|
|
70
73
|
const eol = record.includes('\r\n') ? '\r\n' : '\n';
|
|
71
|
-
const indent = record
|
|
74
|
+
const indent = indentOf(record);
|
|
72
75
|
const line = `${indent}answer: ${answer}`;
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
// Each line with its own line end, so the lines around the answer keep their bytes.
|
|
77
|
+
const parts = record.split(/(?<=\n)/);
|
|
78
|
+
const lines = parts.map((p) => p.replace(/\r?\n$/, ''));
|
|
79
|
+
const at = lines.findIndex((l) => l.startsWith(`${indent}answer:`));
|
|
80
|
+
// An old answer goes with the block under it, if it was one.
|
|
81
|
+
if (at >= 0) {
|
|
82
|
+
const end = blockEnd(lines, at, indent);
|
|
83
|
+
parts.splice(at, end - at, line + (parts[end - 1].match(/\r?\n$/)?.[0] ?? ''));
|
|
84
|
+
|
|
85
|
+
return parts.join('');
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
const body = record.replace(/(\r?\n)+$/, '');
|
|
77
89
|
|
|
78
90
|
return `${body}${eol}${line}${record.slice(body.length) || eol}`;
|