@arjunkhera/atlas 0.2.2 → 0.3.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/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -9
- package/agents/artifact-renderer.md +7 -2
- package/agents/capability-reader.md +1 -1
- package/agents/code-explorer.md +3 -2
- package/agents/reviewer-architect.md +3 -3
- package/agents/reviewer-pm.md +4 -4
- package/agents/reviewer-security.md +11 -8
- package/agents/verifier.md +97 -0
- package/door/cli.mjs +33 -108
- package/door/lib/kind-drift.mjs +16 -6
- package/door/lib/privacy.mjs +61 -0
- package/door/lib/pull-request.mjs +55 -56
- package/door/lib/ste-words.json +51 -0
- package/door/lib/ste.mjs +199 -0
- package/door/lib/tooling.mjs +2 -2
- package/package.json +1 -1
- package/shape/check.mjs +33 -33
- package/shape/releases.json +5 -0
- package/skills/feedback/SKILL.md +39 -0
- package/skills/lead/SKILL.md +103 -0
- package/skills/learnings/SKILL.md +48 -0
- package/skills/repo-skills/SKILL.md +64 -0
- package/skills/sdlc-task/SKILL.md +62 -69
- package/skills/sdlc-task/lifecycle.yaml +8 -4
- package/skills/sdlc-task/templates/design-doc.md +23 -18
- package/skills/sdlc-task/templates/resume-anchor.md +9 -7
- package/work/lib/capability.mjs +34 -23
- package/work/lib/delivery.mjs +26 -12
- package/work/lib/questions.mjs +54 -10
- package/work/lib/verb-fields.mjs +34 -0
- package/work/lib/verbs.mjs +100 -19
- package/work/{manifest-0.5.0.json → manifest-0.6.0.json} +9 -3
- package/work/mcp.mjs +13 -10
- package/work/protected-paths.json +4 -2
- package/door/lib/helpers.mjs +0 -139
- package/door/lib/onboarding.mjs +0 -130
|
@@ -1,70 +1,74 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// The tooling change set becomes one pull request, with the fixed label and a
|
|
2
|
+
// source for each change.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Stage 1 of the target design retired the five helpers and their phrase
|
|
5
|
+
// router (decided 28 September: code that guesses meaning retires). The
|
|
6
|
+
// tooling writer stays, because it is exact work: it copies two files from
|
|
7
|
+
// the package. The onboarding skill of stage 2 names it; until then the lead
|
|
8
|
+
// skill does.
|
|
7
9
|
//
|
|
8
10
|
// The body is built by a pure function, so a test reads it without GitHub. The
|
|
9
11
|
// git and GitHub calls sit below it, in one place.
|
|
10
12
|
import { execFileSync } from 'node:child_process';
|
|
11
13
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
12
14
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { dirname, join } from 'node:path';
|
|
14
|
-
import {
|
|
15
|
+
import { dirname, join, posix } from 'node:path';
|
|
16
|
+
import { CHECK_PATH, WORKFLOW_PATH } from '../../shape/check.mjs';
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
// Every change names where it came from, and the set is closed.
|
|
19
|
+
export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
|
|
20
|
+
|
|
21
|
+
// The one label every tooling pull request carries. A person filters on it.
|
|
22
|
+
export const TOOLING_LABEL = 'atlas-helper';
|
|
23
|
+
|
|
24
|
+
// The only two files the tooling writer may write.
|
|
25
|
+
export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH]);
|
|
26
|
+
|
|
27
|
+
export class ChangeError extends Error {}
|
|
17
28
|
|
|
18
29
|
// A backtick breaks a table cell the same way a pipe does, and a provenance
|
|
19
|
-
// line often carries one. Both are escaped
|
|
30
|
+
// line often carries one. Both are escaped.
|
|
20
31
|
const escapeCell = (text) => String(text ?? '').replaceAll('|', '\\|').replaceAll('`', '\\`').replaceAll('\n', ' ').trim();
|
|
21
32
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
33
|
+
// Every change, checked before anything is written. The writer writes only its
|
|
34
|
+
// two files, and every change names its source.
|
|
35
|
+
export function checkChanges(changes) {
|
|
36
|
+
if (!Array.isArray(changes) || !changes.length) throw new ChangeError('a tooling pull request needs at least one change');
|
|
37
|
+
for (const change of changes) {
|
|
38
|
+
const path = posix.normalize(String(change?.path ?? '')).replace(/^\.\//, '');
|
|
39
|
+
if (!path || path === '.') throw new ChangeError('a change has no path');
|
|
40
|
+
if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
|
|
41
|
+
if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
|
|
42
|
+
const source = change?.provenance?.source;
|
|
43
|
+
if (!PROVENANCE_SOURCES.includes(source)) throw new ChangeError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')}`);
|
|
44
|
+
if (!String(change?.provenance?.detail ?? '').trim()) throw new ChangeError(`the change to "${path}" names the source "${source}" and says nothing about it`);
|
|
32
45
|
}
|
|
33
|
-
return
|
|
46
|
+
return changes;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
// The pull request body. Every change is one row, and every row names where the
|
|
37
|
-
// change came from.
|
|
38
|
-
|
|
39
|
-
export function provenanceBody({ helper, helpers, why, changes, groups, repo = null }) {
|
|
40
|
-
const parts = groups ?? [{ helper, changes }];
|
|
41
|
-
const named = (helpers ?? parts.map((group) => group.helper));
|
|
50
|
+
// change came from.
|
|
51
|
+
export function provenanceBody({ why, changes, repo = null }) {
|
|
42
52
|
const lines = [
|
|
43
|
-
|
|
44
|
-
? `The \`${named[0].title}\` helper wrote this. It owns ${named[0].ownsText}.`
|
|
45
|
-
: `${named.length} helpers wrote this, and it is one pull request because it is one change:\n${named.map((one) => `- \`${one.title}\`, which owns ${one.ownsText}`).join('\n')}`,
|
|
53
|
+
'The Atlas tooling writer wrote this. It copies the repo check and its workflow from the Atlas package.',
|
|
46
54
|
'',
|
|
47
55
|
`**Why.** ${String(why ?? '').trim() || 'Not given.'}`,
|
|
48
56
|
'',
|
|
49
57
|
'## What changed, and where each change came from',
|
|
50
58
|
'',
|
|
51
|
-
'| File |
|
|
52
|
-
'
|
|
59
|
+
'| File | Source | What the source says |',
|
|
60
|
+
'|---|---|---|',
|
|
53
61
|
];
|
|
54
|
-
for (const
|
|
55
|
-
for (const change of group.changes) {
|
|
56
|
-
lines.push(`| \`${escapeCell(change.path)}\` | ${escapeCell(group.helper.title)} | ${escapeCell(change.provenance.source)} | ${escapeCell(change.provenance.detail)} |`);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
62
|
+
for (const change of changes) lines.push(`| \`${escapeCell(change.path)}\` | ${escapeCell(change.provenance.source)} | ${escapeCell(change.provenance.detail)} |`);
|
|
59
63
|
lines.push(
|
|
60
64
|
'',
|
|
61
65
|
'## How to read this',
|
|
62
66
|
'',
|
|
63
|
-
`1.
|
|
67
|
+
`1. Both files are rule files. They steer agents, so a person merges this${repo ? ` in ${repo}` : ''}.`,
|
|
64
68
|
'2. An agent made every change. No person edited these files by hand.',
|
|
65
69
|
`3. "Source" is one of: ${PROVENANCE_SOURCES.join(', ')}.`,
|
|
66
70
|
'',
|
|
67
|
-
`Label: \`${
|
|
71
|
+
`Label: \`${TOOLING_LABEL}\`.`,
|
|
68
72
|
'',
|
|
69
73
|
);
|
|
70
74
|
return lines.join('\n');
|
|
@@ -72,8 +76,8 @@ export function provenanceBody({ helper, helpers, why, changes, groups, repo = n
|
|
|
72
76
|
|
|
73
77
|
// Write a checked change set into a working tree. It writes files and nothing
|
|
74
78
|
// else: no branch, no commit, no network. `open` below does the rest.
|
|
75
|
-
export function applyChanges(root,
|
|
76
|
-
|
|
79
|
+
export function applyChanges(root, changes) {
|
|
80
|
+
checkChanges(changes);
|
|
77
81
|
const written = [];
|
|
78
82
|
for (const change of changes) {
|
|
79
83
|
const path = join(root, change.path);
|
|
@@ -81,41 +85,36 @@ export function applyChanges(root, helperId, changes) {
|
|
|
81
85
|
writeFileSync(path, change.contents);
|
|
82
86
|
written.push(change.path);
|
|
83
87
|
}
|
|
84
|
-
return {
|
|
88
|
+
return { written };
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
const run = (command, args, cwd) => execFileSync(command, args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 8 * 1024 * 1024, env: childEnvironment() }).trim();
|
|
88
92
|
|
|
89
93
|
// The whole pull request, live. Needs git and the GitHub command line.
|
|
90
94
|
//
|
|
91
|
-
// It never merges. A person merges
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const all = checked.flatMap((group) => group.changes);
|
|
97
|
-
// A dry run writes nothing. The first version applied the changes and then
|
|
98
|
-
// printed "Nothing was pushed", which was true and misleading (acceptance
|
|
99
|
-
// review of batch 16, finding S3-5).
|
|
100
|
-
if (!dryRun) for (const group of checked) applyChanges(root, group.helper.id, group.changes);
|
|
95
|
+
// It never merges. A person merges it, because both files steer agents.
|
|
96
|
+
export function open({ root, changes, why, branch, title, base = null, label = TOOLING_LABEL, dryRun = false, exec = run }) {
|
|
97
|
+
checkChanges(changes);
|
|
98
|
+
// A dry run writes nothing.
|
|
99
|
+
if (!dryRun) applyChanges(root, changes);
|
|
101
100
|
const repo = (() => { try { return exec('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], root); } catch { return null; } })();
|
|
102
|
-
const body = provenanceBody({
|
|
101
|
+
const body = provenanceBody({ why, changes, repo });
|
|
103
102
|
const onto = base ?? exec('git', ['symbolic-ref', '--short', 'HEAD'], root);
|
|
104
|
-
const
|
|
105
|
-
if (dryRun) return {
|
|
103
|
+
const written = changes.map((change) => change.path);
|
|
104
|
+
if (dryRun) return { repo, branch, title, body, base: onto, written, opened: false };
|
|
106
105
|
|
|
107
106
|
exec('git', ['checkout', '-b', branch], root);
|
|
108
|
-
exec('git', ['add', '--', ...
|
|
107
|
+
exec('git', ['add', '--', ...written], root);
|
|
109
108
|
exec('git', ['commit', '-m', title, '-m', body], root);
|
|
110
109
|
exec('git', ['push', '-u', 'origin', branch], root);
|
|
111
110
|
// A label that does not exist yet makes `gh pr create` fail, so it is made
|
|
112
111
|
// first. A label that already exists makes that call fail instead, and that
|
|
113
112
|
// failure is the expected one.
|
|
114
|
-
try { exec('gh', ['label', 'create', label, '--description', 'Written by
|
|
113
|
+
try { exec('gh', ['label', 'create', label, '--description', 'Written by the Atlas tooling writer. A person merges it.', '--color', 'C5A882'], root); } catch { /* it already exists */ }
|
|
115
114
|
// --head names the branch. `git push -u` does not always record the upstream
|
|
116
115
|
// in a shallow clone, and then `gh pr create` refuses with "you must first
|
|
117
116
|
// push the current branch" although the branch is already on GitHub
|
|
118
117
|
// (measured on an Engram clone, 2026-09-20).
|
|
119
118
|
const url = exec('gh', ['pr', 'create', '--title', title, '--body', body, '--label', label, '--base', onto, '--head', branch], root);
|
|
120
|
-
return {
|
|
119
|
+
return { repo, branch, title, body, base: onto, written, opened: true, url };
|
|
121
120
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "Words that ASD-STE100 does not approve, each with the plain word to use instead. The check flags them and never rewrites. Add a word here with its plain word; remove one only by a decision.",
|
|
3
|
+
"words": {
|
|
4
|
+
"utilize": "use",
|
|
5
|
+
"utilise": "use",
|
|
6
|
+
"leverage": "use",
|
|
7
|
+
"facilitate": "help",
|
|
8
|
+
"commence": "start",
|
|
9
|
+
"terminate": "stop",
|
|
10
|
+
"approximately": "about",
|
|
11
|
+
"additional": "more",
|
|
12
|
+
"additionally": "also",
|
|
13
|
+
"assist": "help",
|
|
14
|
+
"obtain": "get",
|
|
15
|
+
"prior": "before",
|
|
16
|
+
"subsequent": "next",
|
|
17
|
+
"subsequently": "then",
|
|
18
|
+
"numerous": "many",
|
|
19
|
+
"sufficient": "enough",
|
|
20
|
+
"endeavor": "try",
|
|
21
|
+
"attempt": "try",
|
|
22
|
+
"ascertain": "find",
|
|
23
|
+
"demonstrate": "show",
|
|
24
|
+
"indicate": "show",
|
|
25
|
+
"initiate": "start",
|
|
26
|
+
"modify": "change",
|
|
27
|
+
"purchase": "buy",
|
|
28
|
+
"regarding": "about",
|
|
29
|
+
"therefore": "thus",
|
|
30
|
+
"however": "but",
|
|
31
|
+
"furthermore": "also",
|
|
32
|
+
"moreover": "also",
|
|
33
|
+
"nevertheless": "but",
|
|
34
|
+
"whereas": "but",
|
|
35
|
+
"whilst": "while",
|
|
36
|
+
"amongst": "among",
|
|
37
|
+
"simply": "(remove it)",
|
|
38
|
+
"basically": "(remove it)",
|
|
39
|
+
"essentially": "(remove it)",
|
|
40
|
+
"very": "(remove it, or use an exact word)",
|
|
41
|
+
"really": "(remove it)",
|
|
42
|
+
"actually": "(remove it)",
|
|
43
|
+
"etc": "(name each item)",
|
|
44
|
+
"e.g": "for example",
|
|
45
|
+
"i.e": "that is",
|
|
46
|
+
"via": "through",
|
|
47
|
+
"robust": "strong",
|
|
48
|
+
"seamless": "(say what it does)",
|
|
49
|
+
"seamlessly": "(say what it does)"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/door/lib/ste.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// The STE check. It measures a markdown or HTML page against the limits of
|
|
2
|
+
// ASD-STE100 that code can measure, and flags what breaks them. It never
|
|
3
|
+
// rewrites a word: a person or an agent fixes the text.
|
|
4
|
+
//
|
|
5
|
+
// The limits: 20 words in a sentence of a numbered step, 25 words in any
|
|
6
|
+
// other sentence, 6 sentences in a paragraph, and no word from the list in
|
|
7
|
+
// ste-words.json. Code, block quotes and quoted words are exempt, because
|
|
8
|
+
// they are not the writer's own prose.
|
|
9
|
+
//
|
|
10
|
+
// `--share` adds the privacy filter, with no exemption for quotes: a quoted
|
|
11
|
+
// email address still leaves with the page.
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
+
import { dirname, extname, join, relative } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { privacyFindings, loadPrivateTerms } from './privacy.mjs';
|
|
16
|
+
|
|
17
|
+
export const LIMITS = Object.freeze({ step: 20, sentence: 25, paragraph: 6 });
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
export const WORDS = Object.freeze(JSON.parse(readFileSync(join(HERE, 'ste-words.json'), 'utf8')).words);
|
|
21
|
+
|
|
22
|
+
// Each listed word, with its common forms.
|
|
23
|
+
function forms(word) {
|
|
24
|
+
const out = new Set([word, `${word}s`, `${word}es`, `${word}d`, `${word}ed`, `${word}ing`]);
|
|
25
|
+
if (word.endsWith('y')) { out.add(`${word.slice(0, -1)}ies`); out.add(`${word.slice(0, -1)}ied`); }
|
|
26
|
+
if (word.endsWith('e')) out.add(`${word.slice(0, -1)}ing`);
|
|
27
|
+
return [...out];
|
|
28
|
+
}
|
|
29
|
+
const FORM_TO_WORD = new Map(Object.keys(WORDS).flatMap((word) => forms(word).map((form) => [form, word])));
|
|
30
|
+
|
|
31
|
+
// ------------------------------------------------------------- reading a page
|
|
32
|
+
|
|
33
|
+
// Code and quoted words become one placeholder word each: they are not the
|
|
34
|
+
// writer's prose, but a name still takes a place in the sentence. The quote
|
|
35
|
+
// marks go with the words they hold.
|
|
36
|
+
function stripInline(text) {
|
|
37
|
+
return text
|
|
38
|
+
.replace(/`[^`]*`/g, ' Code ')
|
|
39
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
40
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
41
|
+
.replace(/<https?:[^>]*>/g, ' ')
|
|
42
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
43
|
+
.replace(/"[^"\n]*"/g, ' Quote ')
|
|
44
|
+
.replace(/“[^”\n]*”/g, ' Quote ')
|
|
45
|
+
.replace(/(^|[\s(])'[^'\n]+'(?=[\s.,;:)]|$)/g, '$1 Quote ')
|
|
46
|
+
.replace(/\*\*|__|\*|~~/g, '')
|
|
47
|
+
.replace(/<[^>]+>/g, ' ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Markdown into units: each unit is a paragraph, a list item or a table cell,
|
|
51
|
+
// with the line it starts on and whether it is a numbered step.
|
|
52
|
+
export function markdownUnits(text) {
|
|
53
|
+
const lines = text.split('\n');
|
|
54
|
+
const units = [];
|
|
55
|
+
let current = null;
|
|
56
|
+
let fence = null;
|
|
57
|
+
let comment = false;
|
|
58
|
+
const close = () => { if (current && current.text.trim()) units.push(current); current = null; };
|
|
59
|
+
let index = 0;
|
|
60
|
+
if (lines[0] === '---') {
|
|
61
|
+
const end = lines.indexOf('---', 1);
|
|
62
|
+
if (end > 0) index = end + 1;
|
|
63
|
+
}
|
|
64
|
+
for (; index < lines.length; index += 1) {
|
|
65
|
+
const line = lines[index];
|
|
66
|
+
const number = index + 1;
|
|
67
|
+
const fenceMark = /^\s*(```|~~~)/.exec(line);
|
|
68
|
+
if (fence) { if (fenceMark && fenceMark[1] === fence) fence = null; continue; }
|
|
69
|
+
if (fenceMark) { close(); fence = fenceMark[1]; continue; }
|
|
70
|
+
if (!line.trim()) { close(); continue; }
|
|
71
|
+
if (/^\s*>/.test(line)) { close(); continue; }
|
|
72
|
+
if (comment) { if (line.includes('-->')) comment = false; continue; }
|
|
73
|
+
if (/^\s*<!--/.test(line)) { close(); comment = !line.includes('-->'); continue; }
|
|
74
|
+
if (/^\s*\|/.test(line)) {
|
|
75
|
+
close();
|
|
76
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
77
|
+
for (const cell of line.split('|').slice(1, -1)) if (cell.trim()) units.push({ line: number, step: false, text: cell });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) { close(); units.push({ line: number, step: false, text: line.replace(/^\s*#+\s*/, '') }); continue; }
|
|
81
|
+
const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
82
|
+
const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
83
|
+
if (numbered || bullet) { close(); current = { line: number, step: Boolean(numbered), text: (numbered ?? bullet)[1] }; continue; }
|
|
84
|
+
if (current) current.text += ` ${line.trim()}`;
|
|
85
|
+
else current = { line: number, step: false, text: line.trim() };
|
|
86
|
+
}
|
|
87
|
+
close();
|
|
88
|
+
return units.map((unit) => ({ ...unit, text: stripInline(unit.text) }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ', mdash: '—', ndash: '–', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', hellip: '…' };
|
|
92
|
+
const decode = (text) => text.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
|
|
93
|
+
if (name[0] === '#') return String.fromCodePoint(name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
|
|
94
|
+
return ENTITIES[name.toLowerCase()] ?? whole;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// HTML into the same units. Each block element is a unit; an item of an
|
|
98
|
+
// ordered list is a step. Script, style, code, pre and quotes are exempt.
|
|
99
|
+
export function htmlUnits(text) {
|
|
100
|
+
// Each replacement keeps its line breaks, so a line number in the body is
|
|
101
|
+
// the line number in the page.
|
|
102
|
+
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
103
|
+
const word = (name) => (match) => ` ${name} ${match.replace(/[^\n]/g, '')}`;
|
|
104
|
+
const body = text
|
|
105
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
106
|
+
.replace(/<(script|style|pre|blockquote|svg|template|title|head)\b[\s\S]*?<\/\1>/gi, blank)
|
|
107
|
+
.replace(/<code\b[\s\S]*?<\/code>/gi, word('Code'))
|
|
108
|
+
.replace(/<q\b[\s\S]*?<\/q>/gi, word('Quote'));
|
|
109
|
+
const lineAt = (offset) => body.slice(0, offset).split('\n').length;
|
|
110
|
+
const units = [];
|
|
111
|
+
const BLOCK = /<(p|li|h[1-6]|td|th|dt|dd|figcaption|caption|summary|label|button)\b[^>]*>([\s\S]*?)<\/\1>/gi;
|
|
112
|
+
for (const match of body.matchAll(BLOCK)) {
|
|
113
|
+
const inner = match[2];
|
|
114
|
+
if (/<(p|li|ul|ol|div|table)\b/i.test(inner)) continue;
|
|
115
|
+
let step = false;
|
|
116
|
+
if (match[1].toLowerCase() === 'li') {
|
|
117
|
+
const before = body.slice(0, match.index);
|
|
118
|
+
const lastOl = before.lastIndexOf('<ol');
|
|
119
|
+
const lastUl = before.lastIndexOf('<ul');
|
|
120
|
+
const closedOl = before.lastIndexOf('</ol');
|
|
121
|
+
step = lastOl > lastUl && lastOl > closedOl;
|
|
122
|
+
}
|
|
123
|
+
const plain = decode(stripInline(inner.replace(/<br\s*\/?>/gi, ' '))).replace(/\s+/g, ' ').trim();
|
|
124
|
+
if (plain) units.push({ line: lineAt(match.index), step, text: plain });
|
|
125
|
+
}
|
|
126
|
+
return units;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ------------------------------------------------------------- measuring
|
|
130
|
+
|
|
131
|
+
// A sentence ends at . ! or ? before a space and a capital, a digit or an
|
|
132
|
+
// opening mark, or at the end of the unit. A colon before a list, and a
|
|
133
|
+
// heading, end nothing.
|
|
134
|
+
export function sentences(text) {
|
|
135
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
136
|
+
if (!clean) return [];
|
|
137
|
+
return clean.split(/(?<=[.!?])\s+(?=[A-Z0-9(“"'])/).map((one) => one.trim()).filter((one) => /[A-Za-z0-9]/.test(one));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const words = (sentence) => sentence.split(/\s+/).filter((token) => /[A-Za-z0-9]/.test(token));
|
|
141
|
+
|
|
142
|
+
export function measure(units) {
|
|
143
|
+
const findings = [];
|
|
144
|
+
for (const unit of units) {
|
|
145
|
+
const list = sentences(unit.text);
|
|
146
|
+
if (list.length > LIMITS.paragraph) findings.push({ line: unit.line, rule: 'paragraph', why: `${list.length} sentences in one paragraph; the limit is ${LIMITS.paragraph}` });
|
|
147
|
+
const limit = unit.step ? LIMITS.step : LIMITS.sentence;
|
|
148
|
+
for (const sentence of list) {
|
|
149
|
+
const count = words(sentence).length;
|
|
150
|
+
if (count > limit) findings.push({ line: unit.line, rule: unit.step ? 'step-sentence' : 'sentence', why: `${count} words in one sentence${unit.step ? ' of a numbered step' : ''}; the limit is ${limit}: "${sentence.split(/\s+/).slice(0, 8).join(' ')} …"` });
|
|
151
|
+
}
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
for (const token of unit.text.toLowerCase().match(/[a-z]+(?:\.[a-z])?/g) ?? []) {
|
|
154
|
+
const word = FORM_TO_WORD.get(token);
|
|
155
|
+
if (word && !seen.has(token)) { seen.add(token); findings.push({ line: unit.line, rule: 'word', why: `"${token}" is not an approved word; use ${WORDS[word]}` }); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return findings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ------------------------------------------------------------- files
|
|
162
|
+
|
|
163
|
+
const READABLE = new Set(['.md', '.markdown', '.html', '.htm']);
|
|
164
|
+
const SKIP = new Set(['node_modules', '.git']);
|
|
165
|
+
|
|
166
|
+
export function pagesUnder(path) {
|
|
167
|
+
if (!existsSync(path)) throw new Error(`${path} does not exist`);
|
|
168
|
+
if (statSync(path).isFile()) return [path];
|
|
169
|
+
const out = [];
|
|
170
|
+
const walk = (dir) => {
|
|
171
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
172
|
+
if (SKIP.has(entry)) continue;
|
|
173
|
+
const full = join(dir, entry);
|
|
174
|
+
if (statSync(full).isDirectory()) walk(full);
|
|
175
|
+
else if (READABLE.has(extname(entry).toLowerCase())) out.push(full);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
walk(path);
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function checkText(text, { html = false, share = false, terms = [] } = {}) {
|
|
183
|
+
const findings = measure(html ? htmlUnits(text) : markdownUnits(text));
|
|
184
|
+
if (share) findings.push(...privacyFindings(text, { terms }).map((finding) => ({ ...finding, rule: `privacy:${finding.rule}` })));
|
|
185
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function checkPaths(paths, { share = false, terms = null, base = process.cwd() } = {}) {
|
|
189
|
+
const own = terms ?? (share ? loadPrivateTerms() : []);
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const path of paths) {
|
|
192
|
+
for (const file of pagesUnder(path)) {
|
|
193
|
+
const html = /\.html?$/i.test(file);
|
|
194
|
+
const findings = checkText(readFileSync(file, 'utf8'), { html, share, terms: own });
|
|
195
|
+
results.push({ file: relative(base, file) || file, findings });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return results;
|
|
199
|
+
}
|
package/door/lib/tooling.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The tooling
|
|
1
|
+
// The tooling writer. It writes and refreshes the two files Atlas puts in a
|
|
2
2
|
// repository, and it needs no model and no network to do it.
|
|
3
3
|
//
|
|
4
4
|
// Milestone M2, slice L2 stage one, done line 1: "The tooling helper writes
|
|
@@ -53,7 +53,7 @@ export function isPackageRepo(root, packageRoot) {
|
|
|
53
53
|
} catch { return false; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// May the
|
|
56
|
+
// May the tooling writer write into this repository? Returns nothing, or throws a
|
|
57
57
|
// refusal that says what to do instead.
|
|
58
58
|
const readWorkflow = (root) => { const full = join(root, WORKFLOW_PATH); return existsSync(full) ? readFileSync(full, 'utf8') : null; };
|
|
59
59
|
|
package/package.json
CHANGED