@arjunkhera/atlas 0.2.3 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/README.md CHANGED
@@ -2,15 +2,21 @@
2
2
 
3
3
  Atlas sets how a repository is worked on. It is one Claude Code plugin with:
4
4
 
5
- 1. **The rulebook**, `atlas:sdlc-task`: start, pause, resume, lock and finish
6
- one piece of work.
7
- 2. **Six crews**: a code reader, a capability reader, three design reviewers
8
- (architect, product, security), and a page renderer.
9
- 3. **The Atlas tools**, an MCP server that keeps work items, questions,
5
+ 1. **The lead**, `atlas:lead`: the talk rules, the routing and the command
6
+ list. A repo's entry file says to follow it in every session.
7
+ 2. **The rulebook**, `atlas:sdlc-task`: start, pause, resume, lock and finish
8
+ one piece of work. Three more skills: `repo-skills` adds a procedure the
9
+ right way, `learnings` turns a miss into one fix, and `feedback` files an
10
+ issue.
11
+ 3. **Seven crews**: a code reader, a capability reader, three design reviewers
12
+ (architect, product, security), a page renderer, and the verifier that
13
+ proves a pull request.
14
+ 4. **The Atlas tools**, an MCP server that keeps work items, questions,
10
15
  decisions and delivery marks in an Engram knowledge graph.
11
- 4. **The `atlas` command**: set a repository up, check its Atlas files, and
12
- install or upgrade Atlas itself.
13
- 5. **The repo check**, a file each repository copies so that its CI can check
16
+ 5. **The `atlas` command**: check a repository's Atlas files, write the check
17
+ into it, measure a page against the STE limits, and install or upgrade
18
+ Atlas itself.
19
+ 6. **The repo check**, a file each repository copies so that its CI can check
14
20
  its instruction files with no Atlas install.
15
21
 
16
22
  ## Install
@@ -108,6 +108,11 @@ Apply, per the ratification:
108
108
  Then write to the session scratchpad instead, and say so in your report. The
109
109
  durable reference is the published artifact URL, which the calling session
110
110
  records.
111
+ - **Run the STE check before you return.** Run `atlas ste <page>` and fix by
112
+ hand each sentence, paragraph and word it flags. Quoted words are exempt.
113
+ When the caller says the page goes to anyone other than the owner, run
114
+ `atlas ste --share <page>` too, and remove each `privacy:` finding. Report
115
+ the last result.
111
116
 
112
117
  ## Never-read and never-quote paths (M1 lock, L1; rules in `${CLAUDE_PLUGIN_ROOT}/work/protected-paths.json`)
113
118
 
@@ -116,7 +121,7 @@ M1 design review (finding R12) and the L1 lock block.
116
121
 
117
122
  **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`):
118
123
  `deploy/`, `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`,
119
- `.mcp.json`, `.sdlc/`. Do not open them, do not grep them, do not list
124
+ `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open them, do not grep them, do not list
120
125
  their contents, do not cat them from a shell. If a search hits one of these
121
126
  paths, drop the hit and say so in the digest.
122
127
 
@@ -75,7 +75,7 @@ These deny rules bind every sub-agent that reads a repo. They come from the M1 d
75
75
  (finding R12) and the lock block.
76
76
 
77
77
  **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`): `deploy/`,
78
- `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`, `.mcp.json`, `.sdlc/`. Do not open
78
+ `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`, `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open
79
79
  them, do not grep them, do not list their contents, do not cat them from a shell. If a
80
80
  search hits one of these paths, drop the hit and say so in `unknowns`. The `where_are_we`
81
81
  verb refuses a digest that cites one, so a read of them costs the whole answer.
@@ -93,7 +93,7 @@ M1 design review (finding R12) and the L1 lock block.
93
93
 
94
94
  **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`):
95
95
  `deploy/`, `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`,
96
- `.mcp.json`, `.sdlc/`. Do not open them, do not grep them, do not list
96
+ `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open them, do not grep them, do not list
97
97
  their contents, do not cat them from a shell. If a search hits one of these
98
98
  paths, drop the hit and say so in the digest.
99
99
 
@@ -62,7 +62,7 @@ M1 design review (finding R12) and the L1 lock block.
62
62
 
63
63
  **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`):
64
64
  `deploy/`, `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`,
65
- `.mcp.json`, `.sdlc/`. Do not open them, do not grep them, do not list
65
+ `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open them, do not grep them, do not list
66
66
  their contents, do not cat them from a shell. If a search hits one of these
67
67
  paths, drop the hit and say so in the digest.
68
68
 
@@ -65,7 +65,7 @@ M1 design review (finding R12) and the L1 lock block.
65
65
 
66
66
  **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`):
67
67
  `deploy/`, `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`,
68
- `.mcp.json`, `.sdlc/`. Do not open them, do not grep them, do not list
68
+ `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open them, do not grep them, do not list
69
69
  their contents, do not cat them from a shell. If a search hits one of these
70
70
  paths, drop the hit and say so in the digest.
71
71
 
@@ -69,7 +69,7 @@ M1 design review (finding R12) and the L1 lock block.
69
69
 
70
70
  **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`):
71
71
  `deploy/`, `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`,
72
- `.mcp.json`, `.sdlc/`. Do not open them, do not grep them, do not list
72
+ `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open them, do not grep them, do not list
73
73
  their contents, do not cat them from a shell. If a search hits one of these
74
74
  paths, drop the hit and say so in the digest.
75
75
 
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: verifier
3
+ description: >
4
+ The independent check of one pull request against its definition of done. It works in a
5
+ fresh worktree of the branch, runs its own check for each line, records the command, the
6
+ result and the log, and posts one proof comment on the pull request. It never edits the
7
+ change and never fixes what it finds. The sdlc-task skill calls it before it asks the
8
+ owner for a merge. Read-only for the repo; it never holds a write door.
9
+ model: inherit
10
+ tools: Read, Grep, Glob, Bash
11
+ ---
12
+
13
+ # The verifier
14
+
15
+ You prove a pull request, or you show where it fails. You did not build
16
+ it, and you do not fix it. The builder never edits your proof.
17
+
18
+ ## Input
19
+
20
+ The caller gives you three things:
21
+
22
+ 1. The pull request: its repo and its number, or its branch.
23
+ 2. Its definition of done: a numbered list of lines. Each line says what
24
+ must be true, and the proof it expects.
25
+ 3. The main checkout of the repo, as a path.
26
+
27
+ If one is missing, stop and say which one.
28
+
29
+ ## Steps
30
+
31
+ 1. Make a fresh worktree of the branch outside the main checkout:
32
+ `git -C <main> worktree add <scratch>/verify-<number> <branch>`.
33
+ Work only in that worktree.
34
+ 2. For each line of the definition of done, decide your own check. Do not
35
+ reuse the builder's claim or the builder's log. Prefer a command that
36
+ gives a clear pass or fail.
37
+ 3. Run the check. Record the command, the exit code, and the part of the
38
+ log that proves the result. Keep each log short.
39
+ 4. Mark the line PASS, FAIL or BY HAND. Use BY HAND when a line needs
40
+ what you cannot do, such as a new session on the owner's machine.
41
+ Say what the owner must do for it.
42
+ 5. Write the proof comment to a file in your scratch folder, never in the
43
+ worktree. Use the shape below.
44
+ 6. Run `atlas ste --share <file>` on the comment. Remove or hide each
45
+ `privacy:` finding by hand, and run it again. Post only when no
46
+ `privacy:` finding is left.
47
+ 7. Post the comment once: `gh pr comment <number> --body-file <file>`.
48
+ 8. Remove the worktree: `git -C <main> worktree remove <path>`.
49
+ 9. Report the table to the caller. FAIL on any line means the pull request
50
+ is not ready for the owner.
51
+
52
+ ## Ignored files and secrets
53
+
54
+ A fresh worktree has no ignored files, such as a local secrets file.
55
+
56
+ 1. Some steps need such a file. Then link only the ignored files that the
57
+ procedure names, from the main checkout into the worktree, with
58
+ `ln -s`. Record each link in the proof.
59
+ 2. Never read, print, copy or quote a linked file.
60
+ 3. Run a step that uses a linked file only when the diff changes no code
61
+ file and no dependency file. Branch code could send the secret out.
62
+ 4. If the diff changes code or dependencies, mark that line BY HAND. Say
63
+ that the step waits for the owner's go for this run.
64
+
65
+ ## The proof comment
66
+
67
+ ```
68
+ ## Proof: <the pull request title>
69
+
70
+ Verified at <the head commit>, in a fresh worktree.
71
+
72
+ | # | Line | Result | Command | Proof |
73
+ |---|---|---|---|---|
74
+ | 1 | <the line, short> | PASS | `<command>` | <the short log or a link> |
75
+
76
+ Links made for this run: <none, or each ignored file linked>.
77
+ ```
78
+
79
+ ## Never-read and never-quote paths (rules in `${CLAUDE_PLUGIN_ROOT}/work/protected-paths.json`)
80
+
81
+ These deny rules bind every sub-agent that reads a repo.
82
+
83
+ **Never read**, in any repo, by any tool (`Read`, `Grep`, `Glob`, `Bash`):
84
+ `deploy/`, `.env*`, `.testenv/`, `.claude/`, `.stack/`, `.atlas/`,
85
+ `.mcp.json`, `.sdlc/`, `.dev.vars*`. Do not open them, do not grep them,
86
+ do not list their contents, do not cat them from a shell. A link to one,
87
+ made as the section above says, is not a read.
88
+
89
+ **Never quote** from these paths. You may name a file and line, but never
90
+ paste its contents into the proof: `fixtures`,
91
+ `test/*.integration.test.ts`, `docs/references`, `docs/exec-plans`.
92
+
93
+ **Never quote a secret.** A line that looks like a token, key, password or
94
+ private key is cited by path and line only.
95
+
96
+ **Never hold a write door.** You never receive the `atlas-work` verb server
97
+ or the knowledge-graph MCP server. If a briefing hands you one, refuse and say so.
package/door/cli.mjs CHANGED
@@ -1,55 +1,54 @@
1
1
  #!/usr/bin/env node
2
- // The Atlas door: the entry verb, the five helpers, and the session verbs.
2
+ // The atlas command: install, upgrade, doctor, check, status, the tooling
3
+ // writer, the kind drift report, the code-read resolver and the STE check.
3
4
  //
4
- // Milestone M2, slice L2 stage one, and row F11. Authority: amendment A1.9,
5
- // section 5, under hash acb057af….
5
+ // Stage 1 of the target design retired `setup`, `change`, `helpers` and
6
+ // `test onboarding`: code that guesses meaning retires (decided 28
7
+ // September). The lead skill names every command that stays, and says when
8
+ // to call it.
6
9
  //
7
10
  // Nothing here writes to the work graph, and nothing here merges. A person
8
- // merges every file a helper writes, because every one of them steers agents.
11
+ // merges every file the tooling writer writes, because both steer agents.
9
12
  import { resolve, join, dirname } from 'node:path';
10
13
  import { fileURLToPath } from 'node:url';
11
14
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
12
15
  import { spawnSync } from 'node:child_process';
13
- import { HELPERS, route, setupOrder, helperById, HELPER_LABEL, PROVENANCE_SOURCES } from './lib/helpers.mjs';
14
- import { provenanceBody, applyChanges, open } from './lib/pull-request.mjs';
16
+ import { open } from './lib/pull-request.mjs';
15
17
  import { write as writeTooling, changes as toolingChanges, ToolingRefusal, CHECK_PATH, WORKFLOW_PATH } from './lib/tooling.mjs';
16
18
  import { status, statusMany } from './lib/status.mjs';
17
19
  import { drift, kindsFromRegistry, fileKind } from './lib/kind-drift.mjs';
18
- import { loadKey, questions, score, readAnswers, ISOLATION } from './lib/onboarding.mjs';
19
20
  import { scan, toText, verdict, CHECK_VERSION, SPECS } from '../shape/check.mjs';
20
21
  import { packagePackageVersion } from './lib/releases.mjs';
21
22
  import { install, upgrade, doctor } from './lib/install.mjs';
23
+ import { checkPaths, LIMITS } from './lib/ste.mjs';
24
+ import { loadPrivateTerms } from './lib/privacy.mjs';
22
25
 
23
26
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
27
  const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
25
28
 
26
29
  const HELP = `atlas — the door into a repo's Atlas files
27
30
 
28
- atlas setup --root <repo> what first setup would do, helper by helper
29
- atlas change --root <repo> --say "…" which helpers a change reaches
30
- atlas helpers the five helpers and what each owns
31
-
32
31
  atlas tooling write --root <repo> write both Atlas files into a repo
33
32
  atlas tooling pr --root <repo> the same, as one pull request
34
33
  --replace-unknown also replace a copy that is in no release
35
34
  --workflow rewrite only the workflow, for a repo whose check is current
36
35
  atlas status --root <repo> is the repo's check current, behind, unknown or missing
37
- atlas kind-drift --root <repo> --record-kind <kind>
36
+ atlas kind-drift --root <repo> --record-kind <kinds>
38
37
  does atlas.yaml agree with the repo record
39
38
  atlas check --root <repo> run the repo shape check here
40
39
 
41
40
  atlas code-read resolve … resolve the citations of a code digest
42
41
 
43
- atlas test onboarding --key <path> the six questions for the isolated session
44
- atlas test onboarding --key <path> --answers <file>
45
- score the answers; runs OUTSIDE that session
42
+ atlas ste <file or folder> … measure markdown and HTML against the STE limits
43
+ --share also run the privacy filter, quotes included
44
+ --terms <file> private terms; default ~/.config/atlas/private-terms.txt
46
45
 
47
46
  atlas install install Atlas for every folder on this Mac
48
47
  atlas upgrade [--version <v>] check, show and install a newer release
49
48
  atlas doctor is Atlas installed and working here
50
49
 
51
50
  Atlas ${ATLAS_VERSION}. Check version ${CHECK_VERSION}; it reads spec ${Object.keys(SPECS).join(', ')}.
52
- A person merges every file a helper writes.
51
+ A person merges every file the tooling writer writes.
53
52
  `;
54
53
 
55
54
  function options(argv) {
@@ -69,56 +68,6 @@ function options(argv) {
69
68
  const rootOf = (chosen) => resolve(chosen.root === undefined || chosen.root === true ? process.cwd() : chosen.root);
70
69
  const line = (text = '') => process.stdout.write(`${text}\n`);
71
70
 
72
- function showHelpers() {
73
- line('The five helpers. You never pick one; the entry verb routes what you say.');
74
- line('');
75
- for (const helper of HELPERS) {
76
- line(`${helper.file}. ${helper.title} (${helper.id})`);
77
- line(` owns ${helper.ownsText}`);
78
- line(` for ${helper.forWhat}`);
79
- line(` you say "${helper.youMightSay}"`);
80
- line('');
81
- }
82
- line(`Every helper pull request carries the label "${HELPER_LABEL}".`);
83
- line(`Every change in it names its source: ${PROVENANCE_SOURCES.join(', ')}.`);
84
- }
85
-
86
- function showRoute(chosen) {
87
- const say = chosen.say === true ? '' : String(chosen.say ?? '');
88
- const reached = route(say);
89
- line(`You said: ${say || '(nothing)'}`);
90
- line('');
91
- if (!reached.length) {
92
- line('No helper owns anything in that sentence.');
93
- line('Say which file class changed, or run "atlas helpers" to see the five.');
94
- return 1;
95
- }
96
- line(`It reaches ${reached.length} helper(s), in file order:`);
97
- for (const helper of reached) line(` ${helper.file}. ${helper.title} — ${helper.ownsText}`);
98
- line('');
99
- line('One request, one pull request. A person merges it.');
100
- return 0;
101
- }
102
-
103
- function showSetup(chosen) {
104
- const root = rootOf(chosen);
105
- line(`First setup for ${root}`);
106
- line('');
107
- line('It is the same verb you use later for one change. All five run, in file order.');
108
- line('');
109
- for (const helper of setupOrder()) {
110
- const owned = helper.id === 'tooling'
111
- ? writeTooling(root, PACKAGE_ROOT, { dryRun: true }).rows.map((row) => `${row.path} (${row.state})`).join(', ')
112
- : helper.ownsText;
113
- line(`${helper.file}. ${helper.title}`);
114
- line(` ${owned}`);
115
- }
116
- line('');
117
- line('The tooling helper writes its two files itself; the other four need an agent to draft.');
118
- line('Nothing lands without a person: every file above steers agents.');
119
- return 0;
120
- }
121
-
122
71
  function toolingCommand(chosen) {
123
72
  const root = rootOf(chosen);
124
73
  const what = chosen._[1] ?? 'write';
@@ -130,7 +79,7 @@ function toolingCommand(chosen) {
130
79
  const branch = chosen.branch === true || !chosen.branch ? `atlas/tooling-refresh-${Date.now().toString(36)}` : chosen.branch;
131
80
  const title = `Atlas: refresh the repo shape check to check version ${CHECK_VERSION}`;
132
81
  const why = `The Atlas package ${ATLAS_VERSION} writes both files. A person merges this, because the check steers every pull request.`;
133
- const result = open({ root, helperId: 'tooling', changes, why, branch, title, dryRun: Boolean(chosen['dry-run']) });
82
+ const result = open({ root, changes, why, branch, title, dryRun: Boolean(chosen['dry-run']) });
134
83
  line(result.opened ? `Opened ${result.url}` : 'Dry run. Nothing was pushed.');
135
84
  if (!result.opened) { line(''); line(result.body); }
136
85
  return 0;
@@ -199,6 +148,22 @@ function kindDriftCommand(chosen) {
199
148
  return report.drifting ? 1 : 0;
200
149
  }
201
150
 
151
+ function steCommand(chosen) {
152
+ const paths = chosen._.slice(1);
153
+ if (!paths.length) throw new Error('give a file or a folder: atlas ste <path> [--share]');
154
+ const share = Boolean(chosen.share);
155
+ const terms = share ? loadPrivateTerms({ path: typeof chosen.terms === 'string' ? resolve(chosen.terms) : null }) : [];
156
+ const results = checkPaths(paths.map((one) => resolve(one)), { share, terms });
157
+ let count = 0;
158
+ for (const { file, findings } of results) {
159
+ for (const finding of findings) { count += 1; line(`${file}:${finding.line} ${finding.rule} ${finding.why}`); }
160
+ }
161
+ line('');
162
+ line(`${results.length} page(s) read; ${count} finding(s). Limits: ${LIMITS.step} words a step, ${LIMITS.sentence} words a sentence, ${LIMITS.paragraph} sentences a paragraph.${share ? ` Privacy filter on, with ${terms.length} private term(s).` : ''}`);
163
+ line(count ? 'Fix the text by hand. This check never rewrites.' : 'GREEN');
164
+ return count ? 1 : 0;
165
+ }
166
+
202
167
  function checkCommand(chosen) {
203
168
  const root = rootOf(chosen);
204
169
  const report = scan({ root, repoId: chosen.repo === true ? null : chosen.repo ?? null });
@@ -208,41 +173,6 @@ function checkCommand(chosen) {
208
173
  return decision.green ? 0 : 1;
209
174
  }
210
175
 
211
- function onboardingCommand(chosen) {
212
- const key = loadKey(chosen.key);
213
- if (!chosen.answers || chosen.answers === true) {
214
- line(`The fresh-agent test for ${key.repo}. ${key.tasks.length} tasks, pass is ${key.pass} of ${key.tasks.length}.`);
215
- line('');
216
- line('Set the session up this way, and no other way:');
217
- for (const rule of ISOLATION) line(` - ${rule}`);
218
- line('');
219
- line('Give it these questions, and nothing else. Do not give it this file.');
220
- line('');
221
- for (const task of questions(key)) line(`${task.number}. ${task.ask}`);
222
- line('');
223
- line('Have it write one answer per task, keyed by task id:');
224
- for (const task of key.tasks) line(` ${task.id}: …`);
225
- line('');
226
- line('Then score it from a session that is not that one:');
227
- line(` atlas test onboarding --key ${chosen.key} --answers <file>`);
228
- return 0;
229
- }
230
- const result = score(key, readAnswers(resolve(chosen.answers)));
231
- line(`Fresh-agent test, ${result.repo}: ${result.passed} of ${result.of}.`);
232
- line('');
233
- for (const row of result.rows) {
234
- line(`${row.pass ? 'PASS' : 'FAIL'} ${row.number}. ${row.id}`);
235
- line(` ${row.why}`);
236
- line(` source: ${row.source}`);
237
- }
238
- line('');
239
- line(result.pass
240
- ? `${result.passed} of ${result.of}. The files answer on their own.`
241
- : `${result.passed} of ${result.of}. The files do not answer on their own yet.`);
242
- line('This scorer ran outside the isolated session, so the score is not a self-grade.');
243
- return result.pass ? 0 : 1;
244
- }
245
-
246
176
  export async function main(argv = process.argv.slice(2)) {
247
177
  const command = argv[0];
248
178
  if (command === 'code-read') {
@@ -256,16 +186,11 @@ export async function main(argv = process.argv.slice(2)) {
256
186
  case 'install': return install({ packageRoot: PACKAGE_ROOT, options: chosen, line });
257
187
  case 'upgrade': return upgrade({ packageRoot: PACKAGE_ROOT, options: chosen, line });
258
188
  case 'doctor': return doctor({ packageRoot: PACKAGE_ROOT, options: chosen, line });
259
- case 'helpers': showHelpers(); return 0;
260
- case 'setup': return showSetup(chosen);
261
- case 'change': return showRoute(chosen);
262
189
  case 'tooling': return toolingCommand(chosen);
263
190
  case 'status': return statusCommand(chosen);
264
191
  case 'kind-drift': return kindDriftCommand(chosen);
265
192
  case 'check': return checkCommand(chosen);
266
- case 'test':
267
- if (chosen._[1] !== 'onboarding') throw new Error('the only test is "onboarding"');
268
- return onboardingCommand(chosen);
193
+ case 'ste': return steCommand(chosen);
269
194
  default: throw new Error(`there is no verb "${command}". Run "atlas help".`);
270
195
  }
271
196
  }
@@ -19,8 +19,13 @@
19
19
  import { readFileSync, existsSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { parseYaml, SPECS } from '../../shape/check.mjs';
22
+ import { REPO_KINDS, repoKinds } from '../../work/lib/verb-fields.mjs';
22
23
 
23
- export const KINDS = Object.freeze(['service', 'library', 'schema']);
24
+ // Stage 1: the repo record holds a list of kinds, and atlas.yaml one kind.
25
+ // They agree when the list holds the file's kind. This report retires in
26
+ // stage 2, with the fixed kind tables.
27
+ export const KINDS = REPO_KINDS;
28
+ const asList = (value) => (value === null || value === undefined ? null : (Array.isArray(value) ? value : String(value).split(',').map((one) => one.trim()).filter(Boolean)));
24
29
 
25
30
  // The kind a repository's own file declares, with the repo id beside it.
26
31
  export function fileKind(root) {
@@ -40,6 +45,8 @@ export function fileKind(root) {
40
45
  export function driftOne({ root, recordKind = null, recordRepo = null }) {
41
46
  const file = fileKind(root);
42
47
  const known = (kind) => kind === null || KINDS.includes(kind);
48
+ const recordKinds = asList(recordKind);
49
+ recordKind = recordKinds && recordKinds.length ? recordKinds.join(', ') : null;
43
50
  const row = {
44
51
  root,
45
52
  repo: file.repo ?? recordRepo ?? null,
@@ -52,21 +59,24 @@ export function driftOne({ root, recordKind = null, recordRepo = null }) {
52
59
  if (file.error) { row.state = 'unreadable'; row.why = file.error; return row; }
53
60
  if (!known(file.kind)) { row.state = 'unreadable'; row.why = `atlas.yaml names kind "${file.kind}"; a kind is one of ${KINDS.join(', ')}`; return row; }
54
61
  if (recordKind === null) { row.state = 'unknown'; row.why = 'no repo record kind was given, so there is nothing to compare with. Read it with the registry_list verb and pass it in.'; return row; }
55
- if (!known(recordKind)) { row.state = 'unreadable'; row.why = `the repo record names kind "${recordKind}"; a kind is one of ${KINDS.join(', ')}`; return row; }
62
+ const strange = recordKinds.find((kind) => !known(kind));
63
+ if (strange) { row.state = 'unreadable'; row.why = `the repo record names kind "${strange}"; a kind is one of ${KINDS.join(', ')}`; return row; }
56
64
  if (file.kind === null) { row.state = 'drift'; row.why = `the repo record says "${recordKind}" and atlas.yaml names no kind at all, so CI cannot tell which files this repo needs`; return row; }
57
- if (file.kind === recordKind) { row.state = 'agree'; row.why = `both say "${file.kind}"`; return row; }
65
+ if (recordKinds.includes(file.kind)) { row.state = 'agree'; row.why = recordKinds.length === 1 ? `both say "${file.kind}"` : `the repo record's kinds (${recordKind}) hold "${file.kind}"`; return row; }
58
66
  row.state = 'drift';
59
67
  row.why = `atlas.yaml says "${file.kind}" and the repo record says "${recordKind}". The kind decides which parts are required, so the check in CI and the work verbs are scoring two different repos. Open question O-9 says which one is the source.`;
60
68
  return row;
61
69
  }
62
70
 
63
- // The registry the `registry_list` verb returns, as { repoId: kind }.
71
+ // The registry the `registry_list` verb returns, as { repoId: kinds }. A
72
+ // row from before stage 1 has only kind, and reads as a list of one.
64
73
  export function kindsFromRegistry(registry) {
65
74
  const out = {};
75
+ const kindsOf = (repo) => { const list = repoKinds(repo); return list.length ? list : null; };
66
76
  for (const product of registry?.products ?? []) {
67
- for (const repo of product?.repos ?? []) if (repo?.registry_id) out[repo.registry_id] = repo.kind ?? null;
77
+ for (const repo of product?.repos ?? []) if (repo?.registry_id) out[repo.registry_id] = kindsOf(repo);
68
78
  }
69
- for (const repo of registry?.repos ?? []) if (repo?.registry_id) out[repo.registry_id] = repo.kind ?? null;
79
+ for (const repo of registry?.repos ?? []) if (repo?.registry_id) out[repo.registry_id] = kindsOf(repo);
70
80
  return out;
71
81
  }
72
82
 
@@ -0,0 +1,61 @@
1
+ // The privacy filter. It finds what must not leave with a page or an issue
2
+ // that goes to someone else: a secret, an email address, a home-folder path,
3
+ // an id, or one of the owner's private terms.
4
+ //
5
+ // The secret patterns have one home, the repo shape check, because that file
6
+ // is copied into other repos and may import only Node builtins. This module
7
+ // reads them from there and adds the rest. The release gate imports this
8
+ // module, so a shipped file and a shared page are held to the same patterns.
9
+ //
10
+ // The package is public, so it ships generic patterns only. The owner's own
11
+ // names sit in ~/.config/atlas/private-terms.txt, outside every repo, one
12
+ // term on each line. A line that starts with # is a note.
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { SECRET_PATTERNS, secretPatterns } from '../../shape/check.mjs';
17
+
18
+ export const PRIVATE_TERMS_PATH = join('.config', 'atlas', 'private-terms.txt');
19
+
20
+ // Each pattern is written so that its own source line matches no other
21
+ // pattern here, because this file ships and the release gate reads it.
22
+ export const PRIVACY_PATTERNS = Object.freeze([
23
+ { id: 'email-address', holds: 'An email address.', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/ },
24
+ { id: 'home-folder-path', holds: 'A path into one person\'s home folder.', pattern: /(?:[\\/](?:Users|home)[\\/][A-Za-z0-9._-]+[\\/])|(?:\b[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)/ },
25
+ { id: 'uuid', holds: 'A UUID, the shape of a record, account or session id.', pattern: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i },
26
+ ]);
27
+
28
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+
30
+ // The owner's private terms, or an empty list when the file is not there.
31
+ export function loadPrivateTerms({ path = null, home = homedir() } = {}) {
32
+ const file = path ?? join(home, PRIVATE_TERMS_PATH);
33
+ if (!existsSync(file)) return [];
34
+ return readFileSync(file, 'utf8').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
35
+ }
36
+
37
+ // Every rule this filter applies: the secret patterns, the generic patterns,
38
+ // then one rule for each private term.
39
+ export function privacyRules({ terms = [] } = {}) {
40
+ const secrets = secretPatterns().map((pattern, index) => ({ id: `secret-${SECRET_PATTERNS[index].id}`, holds: SECRET_PATTERNS[index].holds, pattern }));
41
+ const own = terms.map((term) => ({ id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![A-Za-z0-9])${escape(term)}(?![A-Za-z0-9])`, 'i') }));
42
+ return [...secrets, ...PRIVACY_PATTERNS, ...own];
43
+ }
44
+
45
+ // Each finding names its line and its rule, never the text it matched, so a
46
+ // report of a leak does not leak it again.
47
+ export function privacyFindings(text, { terms = [], rules = privacyRules({ terms }) } = {}) {
48
+ const findings = [];
49
+ text.split('\n').forEach((line, index) => {
50
+ for (const rule of rules) if (rule.pattern.test(line)) findings.push({ line: index + 1, rule: rule.id, why: rule.holds });
51
+ });
52
+ return findings;
53
+ }
54
+
55
+ // The same text with each match replaced by a marker. The verifier runs its
56
+ // logs through this before it posts them.
57
+ export function redact(text, { terms = [], rules = privacyRules({ terms }) } = {}) {
58
+ let out = text;
59
+ for (const rule of rules) out = out.replace(new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : `${rule.pattern.flags}g`), `[hidden: ${rule.id}]`);
60
+ return out;
61
+ }